diff --git a/.plans/17-claude-agent.md b/.plans/17-claude-agent.md new file mode 100644 index 0000000000..a2d906e0e0 --- /dev/null +++ b/.plans/17-claude-agent.md @@ -0,0 +1,441 @@ +# Plan: Claude Code Integration (Orchestration Architecture) + +## Why this plan was rewritten + +The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: + +1. `orchestration.dispatchCommand` (client intent) +2. `OrchestrationEngine` (decide + persist + publish domain events) +3. `ProviderCommandReactor` (domain intent -> `ProviderService`) +4. `ProviderService` (adapter routing + canonical runtime stream) +5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) +6. `orchestration.domainEvent` (single push channel consumed by web) + +Claude integration must plug into this path instead of reintroducing legacy provider-specific flows. + +--- + +## Current constraints to design around (post-Stage 1) + +1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. +2. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed. +3. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`. +4. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`. +5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. +6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. + +--- + +## Architecture target + +Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. + +Key decisions: + +1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. +2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. +3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. +4. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally. + +--- + +## Phase 1: Contracts and command shape updates + +### 1.1 Provider-aware model contract + +Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. + +Expected outcomes: + +1. Introduce provider-scoped model lists (Codex + Claude). +2. Add helpers that resolve model by provider. +3. Preserve backwards compatibility for existing Codex defaults. + +### 1.2 Turn-start provider intent + +Update `packages/contracts/src/orchestration.ts`: + +1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. +2. Carry provider through `ThreadTurnStartRequestedPayload`. +3. Keep existing command valid when provider is omitted. + +This removes the implicit “Codex unless session already exists” behavior as the only path. + +### 1.3 Provider session start input for Claude runtime knobs (completed) + +Update `packages/contracts/src/provider.ts`: + +1. Move provider-specific start fields into typed `providerOptions`: + - `providerOptions.codex` + - `providerOptions.claudeCode` +2. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`. +3. Deprecate/remove `resumeThreadId` from the generic start contract. +4. Treat `resumeCursor` as adapter-owned opaque state. + +### 1.4 Contract tests (completed) + +Update/add tests in `packages/contracts/src/*.test.ts` for: + +1. New command payload shape. +2. Provider-aware model resolution behavior. +3. Breaking-change expectations for removed top-level provider fields. + +--- + +## Phase 2: Claude adapter implementation + +### 2.1 Add adapter service + layer + +Create: + +1. `apps/server/src/provider/Services/ClaudeAdapter.ts` +2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` + +Adapter must implement `ProviderAdapterShape`. + +### 2.1.a SDK dependency and baseline config + +Add server dependency: + +1. `@anthropic-ai/claude-agent-sdk` + +Baseline adapter options to support from day one: + +1. `cwd` +2. `model` +3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) +4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) +5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) +6. `resume` +7. `resumeSessionAt` +8. `includePartialMessages` +9. `canUseTool` +10. `hooks` +11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) + +### 2.2 Claude runtime bridge + +Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. + +Required capabilities: + +1. Long-lived session context per adapter session. +2. Multi-turn input queue. +3. Interrupt support. +4. Approval request/response bridge. +5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). + +#### 2.2.a Agent SDK details to preserve + +The adapter should explicitly rely on these SDK capabilities: + +1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). +2. Multi-turn input is supported via async-iterable prompt input. +3. Tool approval decisions are provided via `canUseTool`. +4. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`. +5. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events. + +#### 2.2.b Effect-native session lifecycle skeleton + +```ts +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { Effect } from "effect"; + +const acquireSession = (input: ProviderSessionStartInput) => + Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const claudeOptions = input.providerOptions?.claudeCode; + const resumeState = readClaudeResumeState(input.resumeCursor); + const abortController = new AbortController(); + const result = query({ + prompt: makePromptAsyncIterable(), + options: { + cwd: input.cwd, + model: input.model, + permissionMode: claudeOptions?.permissionMode, + maxThinkingTokens: claudeOptions?.maxThinkingTokens, + pathToClaudeCodeExecutable: claudeOptions?.binaryPath, + resume: resumeState?.threadId, + resumeSessionAt: resumeState?.sessionAt, + signal: abortController.signal, + includePartialMessages: true, + canUseTool: makeCanUseTool(), + hooks: makeClaudeHooks(), + }, + }); + return { abortController, result }; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: "claudeCode", + sessionId: "pending", + detail: "Failed to start Claude runtime session.", + cause, + }), + }), + ({ abortController }) => Effect.sync(() => abortController.abort()), + ); +``` + +#### 2.2.c AsyncIterable -> Effect Stream integration + +Preferred when available in the pinned Effect version: + +```ts +const sdkMessageStream = Stream.fromAsyncIterable( + session.result, + (cause) => + new ProviderAdapterProcessError({ + provider: "claudeCode", + sessionId, + detail: "Claude runtime stream failed.", + cause, + }), +); +``` + +Portable fallback (already aligned with current server patterns): + +```ts +const sdkMessageStream = Stream.async((emit) => { + let cancelled = false; + void (async () => { + try { + for await (const message of session.result) { + if (cancelled) break; + emit.single(message); + } + emit.end(); + } catch (cause) { + emit.fail( + new ProviderAdapterProcessError({ + provider: "claudeCode", + sessionId, + detail: "Claude runtime stream failed.", + cause, + }), + ); + } + })(); + return Effect.sync(() => { + cancelled = true; + }); +}); +``` + +### 2.3 Canonical event mapping + +Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. + +Initial mapping target: + +1. assistant text deltas -> `content.delta` +2. final assistant text -> `item.completed` and/or `turn.completed` +3. approval requests -> `request.opened` +4. approval results -> `request.resolved` +5. system lifecycle -> `session.*`, `thread.*`, `turn.*` +6. errors -> `runtime.error` +7. plan/proposed-plan content when derivable + +Implementation note: + +1. Keep raw Claude message on `raw` for debugging. +2. Prefer canonical item/request kinds over provider-native enums. +3. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently. + +### 2.4 Resume cursor strategy + +Define Claude-owned opaque resume state, e.g.: + +```ts +interface ClaudeResumeCursor { + readonly version: 1; + readonly threadId?: string; + readonly sessionAt?: string; +} +``` + +Rules: + +1. Serialize only adapter-owned state into `resumeCursor`. +2. Parse/validate only inside Claude adapter. +3. Store updated cursor when Claude runtime yields enough data to resume safely. +4. Never overload orchestration thread id as Claude thread id. + +### 2.5 Interrupt and stop semantics + +Map orchestration stop/interrupt expectations onto SDK controls: + +1. `interruptTurn()` -> active query interrupt. +2. `stopSession()` -> close session resources and prevent future sends. +3. `rollbackThread()` -> see Phase 4. + +--- + +## Phase 3: Provider service and composition + +### 3.1 Register Claude adapter + +Update provider registry layer to include Claude: + +1. add `claudeCode` -> `ClaudeAdapter` +2. ensure `ProviderService.listProviderStatuses()` reports Claude availability + +### 3.2 Persist provider binding + +Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. + +Required validation: + +1. Claude bindings survive restart. +2. resume cursor remains opaque and round-trips untouched. +3. stopAll + restart can recover Claude sessions when possible. + +### 3.3 Provider start routing + +Update `ProviderCommandReactor` / orchestration flow: + +1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. +2. If a thread already has Claude session binding, reuse it. +3. If provider switches between Codex and Claude, explicitly stop/rebind before next send. + +--- + +## Phase 4: Checkpoint and revert strategy + +Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. + +Pick one explicit strategy: + +### Option A: provider-native rewind + +If SDK/runtime supports safe rewind: + +1. implement in Claude adapter +2. keep `CheckpointReactor` unchanged + +### Option B: session restart + state truncation shim + +If no native rewind exists: + +1. Claude adapter returns successful rollback by: + - stopping current Claude session + - clearing/rewriting stored Claude resume cursor to last safe resumable point + - forcing next turn to recreate session from persisted orchestration state +2. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion. + +Whichever option is chosen: + +1. behavior must be deterministic +2. checkpoint revert tests must pass under orchestration expectations +3. user-visible activity log should explain failures clearly when provider rollback is impossible + +--- + +## Phase 5: Web integration + +### 5.1 Provider picker and model picker + +Update web state/UI: + +1. allow choosing Claude as thread provider before first turn +2. show Claude model list from provider-aware model helpers +3. preserve existing Codex default behavior when provider omitted + +Likely touch points: + +1. `apps/web/src/store.ts` +2. `apps/web/src/components/ChatView.tsx` +3. `apps/web/src/types.ts` +4. `packages/shared/src/model.ts` + +### 5.2 Settings for Claude executable/options + +Add app settings if needed for: + +1. Claude binary path +2. default permission mode +3. default max thinking tokens + +Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`. + +### 5.3 Session rendering + +No new WS channel should be needed. Claude should appear through existing: + +1. thread messages +2. activities/worklog +3. approvals +4. session state +5. checkpoints/diffs + +--- + +## Phase 6: Testing strategy + +### 6.1 Contract tests + +Cover: + +1. provider-aware model schemas +2. provider field on turn-start command +3. provider-specific start options schema + +### 6.2 Adapter layer tests + +Add `ClaudeAdapter.test.ts` covering: + +1. session start +2. event mapping +3. approval bridge +4. resume cursor parse/serialize +5. interrupt behavior +6. rollback behavior or explicit unsupported error path + +Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests. + +### 6.3 Provider service integration tests + +Extend provider integration coverage so Claude is exercised through `ProviderService`: + +1. start Claude session +2. send turn +3. receive canonical runtime events +4. restart/recover using persisted binding + +### 6.4 Orchestration integration tests + +Add/extend integration tests around: + +1. first-turn provider selection +2. Claude approval requests routed through orchestration +3. Claude runtime ingestion -> messages/activities/session updates +4. checkpoint revert behavior under Claude +5. stopAll/restart recovery + +These should validate real orchestration flows, not just adapter behavior. + +--- + +## Phase 7: Rollout order + +Recommended implementation order: + +1. contracts/provider-aware models +2. provider field on turn-start +3. Claude adapter skeleton + start/send/stream +4. canonical event mapping +5. provider registry/service wiring +6. orchestration recovery + checkpoint strategy +7. web provider/model picker +8. full integration tests + +--- + +## Non-goals + +1. Reintroducing provider-specific WS methods/channels. +2. Storing provider-native thread ids as orchestration ids. +3. Bypassing orchestration engine for Claude-specific UI flows. +4. Encoding Claude resume semantics outside adapter-owned `resumeCursor`. diff --git a/AGENTS.md b/AGENTS.md index 06e7370171..cea5090cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ## Project Snapshot -T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). +T3 Code is a minimal web GUI for using coding agents like Codex and Claude. This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. diff --git a/README.md b/README.md index 03e81b5fb9..5f842e3709 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. +T3 Code is a minimal web GUI for coding agents (currently Codex and Claude, more coming soon). ## How to use diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 69e73da0aa..2b8485c57c 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -2,6 +2,25 @@ import { describe, expect, it, vi } from "vitest"; import { syncShellEnvironment } from "./syncShellEnvironment"; +const EXPECTED_ENV_KEYS = [ + "PATH", + "SSH_AUTH_SOCK", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_MAX_OUTPUT_TOKENS", + "MAX_THINKING_TOKENS", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", +]; + describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { const env: NodeJS.ProcessEnv = { @@ -18,7 +37,7 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", EXPECTED_ENV_KEYS); expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); @@ -82,4 +101,90 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("hydrates missing AWS/Bedrock env vars from the login shell", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/usr/bin", + AWS_REGION: "us-west-2", + AWS_PROFILE: "bedrock-dev", + CLAUDE_CODE_USE_BEDROCK: "1", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.AWS_REGION).toBe("us-west-2"); + expect(env.AWS_PROFILE).toBe("bedrock-dev"); + expect(env.CLAUDE_CODE_USE_BEDROCK).toBe("1"); + }); + + it("preserves inherited AWS env vars over login shell values", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + AWS_REGION: "eu-west-1", + AWS_PROFILE: "production", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/usr/bin", + AWS_REGION: "us-west-2", + AWS_PROFILE: "bedrock-dev", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.AWS_REGION).toBe("eu-west-1"); + expect(env.AWS_PROFILE).toBe("production"); + }); + + it("hydrates Anthropic model env vars from the login shell", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/usr/bin", + CLAUDE_CODE_USE_BEDROCK: "1", + AWS_REGION: "eu-west-1", + ANTHROPIC_MODEL: "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/abc[1m]", + ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION: "eu-west-1", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/haiku", + ANTHROPIC_DEFAULT_SONNET_MODEL: "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/sonnet[1m]", + ANTHROPIC_DEFAULT_OPUS_MODEL: "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/opus[1m]", + CLAUDE_CODE_MAX_OUTPUT_TOKENS: "4096", + MAX_THINKING_TOKENS: "1024", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.CLAUDE_CODE_USE_BEDROCK).toBe("1"); + expect(env.AWS_REGION).toBe("eu-west-1"); + expect(env.ANTHROPIC_MODEL).toBe( + "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/abc[1m]", + ); + expect(env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION).toBe("eu-west-1"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe( + "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/haiku", + ); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe( + "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/sonnet[1m]", + ); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( + "arn:aws:bedrock:eu-west-1:123456:application-inference-profile/opus[1m]", + ); + expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("4096"); + expect(env.MAX_THINKING_TOKENS).toBe("1024"); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 2181bea0ca..deef97497c 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,5 +1,22 @@ import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell"; +const CLAUDE_BEDROCK_ENV_VARS = [ + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_MAX_OUTPUT_TOKENS", + "MAX_THINKING_TOKENS", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", +] as const; + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { @@ -14,6 +31,7 @@ export function syncShellEnvironment( const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ "PATH", "SSH_AUTH_SOCK", + ...CLAUDE_BEDROCK_ENV_VARS, ]); if (shellEnvironment.PATH) { @@ -23,6 +41,12 @@ export function syncShellEnvironment( if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + + for (const key of CLAUDE_BEDROCK_ENV_VARS) { + if (!env[key] && shellEnvironment[key]) { + env[key] = shellEnvironment[key]; + } + } } catch { // Keep inherited environment if shell lookup fails. } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c5eb125aba..c237246ab2 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, + ProviderKind, type OrchestrationEvent, type OrchestrationThread, } from "@t3tools/contracts"; @@ -205,7 +206,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: ProviderKind; readonly realCodex?: boolean; } diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 017c59e2c8..9c87d9821a 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -10,6 +10,7 @@ import { ProviderTurnStartResult, ThreadId, TurnId, + ProviderKind, } from "@t3tools/contracts"; import { Effect, Queue, Stream } from "effect"; @@ -35,7 +36,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -177,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -197,7 +198,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: ProviderKind; } function nowIso(): string { @@ -205,7 +206,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: ProviderKind, threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -215,7 +216,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: ProviderKind, 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 42dcfe34f8..104a6e5e00 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -5,9 +5,11 @@ import { ApprovalRequestId, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_MODEL_BY_PROVIDER, EventId, MessageId, ProjectId, + ProviderKind, ThreadId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -36,7 +38,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 = ProviderKind; function nowIso() { return new Date().toISOString(); @@ -105,6 +107,8 @@ function withRealCodexHarness( const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); + const provider = harness.adapterHarness?.provider ?? "codex"; + const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; yield* harness.engine.dispatch({ type: "project.create", @@ -112,7 +116,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModel: "gpt-5-codex", + defaultModel, createdAt, }); @@ -122,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", - model: "gpt-5-codex", + model: defaultModel, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -882,3 +886,420 @@ it.live( }), ), ); + +it.live("starts a claudeAgent 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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: "claudeAgent", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeAgent" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeAgent"); + }), + "claudeAgent", + ), +); + +it.live("recovers claudeAgent sessions after provider 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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: "claudeAgent", + }); + + 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, + "provider stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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", + }); + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeAgent" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ); + assert.equal(recoveredThread.session?.providerName, "claudeAgent"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeAgent", + ), +); + +it.live("forwards claudeAgent 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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: "claudeAgent", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + assert.equal(thread.session?.threadId, "thread-1"); + + 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(), + }); + + yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeAgent", + ), +); + +it.live("forwards thread.turn.interrupt to claudeAgent 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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: "claudeAgent", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1", + ); + assert.equal(thread.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); + }), + "claudeAgent", + ), +); + +it.live("reverts claudeAgent 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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: "claudeAgent", + }); + + 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", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), + 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", "claudeAgent"), + 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", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeAgent", + ); + + 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.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeAgent", + ), +); diff --git a/apps/server/package.json b/apps/server/package.json index a6ffd53b8b..4658dd9cd0 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.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 09773b71dc..4d339ba72e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -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: ProviderKind; 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?: ProviderKind; }) { 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), @@ -477,6 +478,67 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); + it("captures pre-turn and completion checkpoints for claude runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeAgent", + }); + 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: "claudeAgent", + 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: "claudeAgent", + 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: "claudeAgent", + 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("appends capture failure activity when turn diff summary cannot be derived", 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 claude sessions", async () => { + const harness = await createHarness({ providerName: "claudeAgent" }); + 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: "claudeAgent", + 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/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f9..65fd441302 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -83,9 +83,13 @@ describe("ProviderCommandReactor", () => { createdStateDirs.clear(); }); - async function createHarness(input?: { readonly stateDir?: string }) { + async function createHarness(input?: { + readonly stateDir?: string; + readonly threadModel?: string; + }) { const now = new Date().toISOString(); const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + const threadModel = input?.threadModel ?? "gpt-5-codex"; createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; @@ -96,7 +100,7 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || input.provider === "claudeAgent") ? input.provider : "codex"; const resumeCursor = @@ -129,7 +133,7 @@ describe("ProviderCommandReactor", () => { : "full-access", ...(model !== undefined ? { model } : {}), threadId, - resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, + resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, createdAt: now, updatedAt: now, }; @@ -228,7 +232,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", - defaultModel: "gpt-5-codex", + defaultModel: threadModel, createdAt: now, }), ); @@ -239,7 +243,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + model: threadModel, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -351,6 +355,106 @@ describe("ProviderCommandReactor", () => { }); }); + it("forwards claude effort options through session start and turn send", async () => { + const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); + 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: "claudeAgent", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + 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: "claudeAgent", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, + }); + }); + + it("forwards claude fast mode options through session start and turn send", async () => { + const harness = await createHarness({ threadModel: "claude-opus-4-6" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-fast-mode"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-claude-fast-mode"), + role: "user", + text: "hello with fast mode", + attachments: [], + }, + provider: "claudeAgent", + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + 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: "claudeAgent", + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + }); + }); + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -389,6 +493,102 @@ describe("ProviderCommandReactor", () => { }); }); + it("rejects a first turn when requested provider conflicts with the thread model", 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-provider-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-first"), + role: "user", + text: "hello claude", + attachments: [], + }, + provider: "claudeAgent", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session).toBeNull(); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + summary: "Provider turn start failed", + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); + }); + + it("rejects a turn when the requested model belongs to a different provider", 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-model-provider-mismatch"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-model-provider-mismatch"), + role: "user", + text: "hello", + attachments: [], + }, + model: "claude-sonnet-4-6", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn).not.toHaveBeenCalled(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("does not belong to provider 'codex'"), + }, + }); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -435,6 +635,73 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("restarts claude sessions when claude effort changes", async () => { + const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); + 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: "claudeAgent", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + 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: "claudeAgent", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + 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: "claudeAgent", + modelOptions: { + claudeAgent: { + 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(); @@ -509,7 +776,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - resumeCursor: { opaque: "cursor-1" }, + resumeCursor: { opaque: "resume-1" }, runtimeMode: "approval-required", }); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ @@ -522,6 +789,77 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("rejects provider changes after a thread is already bound to a session provider", 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-provider-switch-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-1"), + role: "user", + text: "first", + attachments: [], + }, + 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-provider-switch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "claudeAgent", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.providerName).toBe("codex"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); + }); + it("does not stop the active session when restart fails before rebind", 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 d8203bd93d..d190b97c63 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -5,7 +5,7 @@ import { EventId, type OrchestrationEvent, type ProviderModelOptions, - type ProviderKind, + ProviderKind, type ProviderStartOptions, type OrchestrationSession, ThreadId, @@ -26,6 +26,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { inferProviderForModel } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -75,6 +76,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)) { @@ -137,6 +143,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); + const threadModelOptions = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -206,9 +213,30 @@ const make = Effect.gen(function* () { } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; - const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; + const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)( + thread.session?.providerName, + ) + ? thread.session.providerName + : undefined; + const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); + if (options?.provider !== undefined && options.provider !== threadProvider) { + return yield* new ProviderAdapterRequestError({ + provider: threadProvider, + method: "thread.turn.start", + detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`, + }); + } + if ( + options?.model !== undefined && + inferProviderForModel(options.model, threadProvider) !== threadProvider + ) { + return yield* new ProviderAdapterRequestError({ + provider: threadProvider, + method: "thread.turn.start", + detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, + }); + } + const preferredProvider: ProviderKind = currentProvider ?? threadProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -268,13 +296,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 === "claudeAgent" && + 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", { @@ -288,6 +326,7 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, + shouldRestartForModelOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession({ @@ -327,15 +366,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 @@ -477,7 +519,18 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, - }); + }).pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail: Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + ); }); const processTurnInterruptRequested = Effect.fnUntraced(function* ( @@ -626,13 +679,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/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7edf..84276011ae 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { + OrchestrationReadModel, + ProviderKind, + ProviderRuntimeEvent, +} from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -45,7 +49,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -402,6 +406,60 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { + const harness = await createHarness(); + const seededAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-claude-placeholder"), + provider: "claudeAgent", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-claude-placeholder", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-claude-placeholder"), + provider: "claudeAgent", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + status: "completed", + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + it("ignores auxiliary turn completions from a different provider thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1354,6 +1412,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { taskId: "turn-task-1", description: "Comparing the desktop rollout chunks to the app-server stream.", + summary: "Code reviewer is validating the desktop rollout chunks.", }, }); @@ -1416,8 +1475,9 @@ describe("ProviderRuntimeIngestion", () => { expect(started?.kind).toBe("task.started"); expect(started?.summary).toBe("Plan task started"); expect(progress?.kind).toBe("task.progress"); - expect(progressPayload?.detail).toBe( - "Comparing the desktop rollout chunks to the app-server stream.", + expect(progressPayload?.detail).toBe("Code reviewer is validating the desktop rollout chunks."); + expect(progressPayload?.summary).toBe( + "Code reviewer is validating the desktop rollout chunks.", ); expect(completed?.kind).toBe("task.completed"); expect(completedPayload?.detail).toBe("\n# Plan title\n"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d4..49970be920 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -370,7 +370,8 @@ function runtimeEventToActivities( summary: "Reasoning update", payload: { taskId: event.payload.taskId, - detail: truncateDetail(event.payload.description), + detail: truncateDetail(event.payload.summary ?? event.payload.description), + ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts new file mode 100644 index 0000000000..556ea304dc --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -0,0 +1,2139 @@ +import type { + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Random, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.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?: ClaudeAdapterLiveOptions["nativeEventLogger"]; +}): Harness { + const query = new FakeClaudeQuery(); + let createInput: + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; + + const adapterOptions: ClaudeAdapterLiveOptions = { + createQuery: (input) => { + createInput = input; + return query; + }, + ...(config?.nativeEventLogger + ? { + nativeEventLogger: config.nativeEventLogger, + } + : {}), + ...(config?.nativeEventLogPath + ? { + nativeEventLogPath: config.nativeEventLogPath, + } + : {}), + }; + + return { + layer: makeClaudeAdapterLive(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, + }; +} + +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"); + +describe("ClaudeAdapterLive", () => { + it.effect("returns validation error for non-claude provider on startSession", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + 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: "claudeAgent", + operation: "startSession", + issue: "Expected provider 'claudeAgent' 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* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + providerOptions: { + claudeAgent: { + 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("forwards claude effort levels into query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-opus-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("ignores unsupported max effort for Sonnet 4.6", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-sonnet-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("ignores adaptive effort for Haiku 4.5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-haiku-4-5", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + effort: "high", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-haiku-4-5", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + thinking: false, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settings, { + alwaysThinkingEnabled: false, + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("ignores Claude thinking toggle for non-Haiku models", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-sonnet-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + thinking: false, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.settings, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards claude fast mode into SDK settings", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-opus-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settings, { + fastMode: true, + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("ignores claude fast mode for non-opus models", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-sonnet-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.settings, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-sonnet-4-6", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + effort: "ultrathink", + }, + }, + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Investigate the edge cases", + attachments: [], + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, undefined); + 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* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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-0", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "", + }, + }, + } as unknown as SDKMessage); + + 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_stop", + index: 0, + }, + } 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_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-4", + 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.completed", + "item.started", + "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 assistantCompletedIndex = runtimeEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + const toolStartedIndex = runtimeEvents.findIndex((event) => event.type === "item.started"); + assert.equal( + assistantCompletedIndex >= 0 && + toolStartedIndex >= 0 && + assistantCompletedIndex < toolStartedIndex, + true, + ); + + 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("maps Claude reasoning deltas, streamed tool inputs, and tool results", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-tool-streams", + uuid: "stream-thinking", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "thinking_delta", + thinking: "Let", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-tool-streams", + uuid: "stream-tool-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-grep-1", + name: "Grep", + input: {}, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-tool-streams", + uuid: "stream-tool-input-1", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 1, + delta: { + type: "input_json_delta", + partial_json: '{"pattern":"foo","path":"src"}', + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-tool-streams", + uuid: "stream-tool-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "user", + session_id: "sdk-session-tool-streams", + uuid: "user-tool-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-grep-1", + content: "src/example.ts:1:foo", + }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-tool-streams", + uuid: "result-tool-streams", + } 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.updated", + "item.updated", + "item.completed", + "turn.completed", + ], + ); + + const reasoningDelta = runtimeEvents.find( + (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text", + ); + assert.equal(reasoningDelta?.type, "content.delta"); + if (reasoningDelta?.type === "content.delta") { + assert.equal(reasoningDelta.payload.delta, "Let"); + assert.equal(String(reasoningDelta.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, "dynamic_tool_call"); + } + + const toolInputUpdated = runtimeEvents.find( + (event) => + event.type === "item.updated" && + (event.payload.data as { input?: { pattern?: string; path?: string } } | undefined)?.input + ?.pattern === "foo", + ); + assert.equal(toolInputUpdated?.type, "item.updated"); + if (toolInputUpdated?.type === "item.updated") { + assert.deepEqual(toolInputUpdated.payload.data, { + toolName: "Grep", + input: { + pattern: "foo", + path: "src", + }, + }); + } + + const toolResultUpdated = runtimeEvents.find( + (event) => + event.type === "item.updated" && + (event.payload.data as { result?: { tool_use_id?: string } } | undefined)?.result + ?.tool_use_id === "tool-grep-1", + ); + assert.equal(toolResultUpdated?.type, "item.updated"); + if (toolResultUpdated?.type === "item.updated") { + assert.equal( + ( + toolResultUpdated.payload.data as { + result?: { content?: string }; + } + ).result?.content, + "src/example.ts:1:foo", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "delegate this", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-task", + uuid: "stream-task-1", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-task-1", + name: "Task", + input: { + description: "Review the database layer", + prompt: "Audit the SQL changes", + subagent_type: "code-reviewer", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-task", + uuid: "assistant-task-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-task-1", + content: [{ type: "text", text: "Delegated" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-task", + uuid: "result-task-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + 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, "collab_agent_tool_call"); + assert.equal(toolStarted.payload.title, "Subagent task"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards Claude task progress summaries for subagent updates", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-subagent-1", + description: "Running background teammate", + summary: "Code reviewer checked the migration edge cases.", + usage: { + total_tokens: 123, + tool_uses: 4, + duration_ms: 987, + }, + session_id: "sdk-session-task-summary", + uuid: "task-progress-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const progressEvent = runtimeEvents.find((event) => event.type === "task.progress"); + assert.equal(progressEvent?.type, "task.progress"); + if (progressEvent?.type === "task.progress") { + assert.equal( + progressEvent.payload.summary, + "Code reviewer checked the migration edge cases.", + ); + assert.equal(progressEvent.payload.description, "Running background teammate"); + } + }).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* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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", + "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* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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", + "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("segments Claude assistant text blocks around tool calls", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-1-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-1-delta", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "First message.", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-1-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 0, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-tool-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-interleaved-1", + name: "Grep", + input: { + pattern: "assistant", + path: "src", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-tool-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "user", + session_id: "sdk-session-interleaved", + uuid: "user-tool-result-interleaved", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-interleaved-1", + content: "src/example.ts:1:assistant", + }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-2-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 2, + content_block: { + type: "text", + text: "", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-2-delta", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 2, + delta: { + type: "text_delta", + text: "Second message.", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-interleaved", + uuid: "stream-text-2-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 2, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-interleaved", + uuid: "result-interleaved", + } 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.completed", + "item.started", + "item.updated", + "item.completed", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const assistantTextDeltas = runtimeEvents.filter( + (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + assert.equal(assistantTextDeltas.length, 2); + if (assistantTextDeltas.length !== 2) { + return; + } + const [firstAssistantDelta, secondAssistantDelta] = assistantTextDeltas; + if (!firstAssistantDelta || !secondAssistantDelta) { + return; + } + assert.notEqual(String(firstAssistantDelta.itemId), String(secondAssistantDelta.itemId)); + + const firstAssistantCompletedIndex = runtimeEvents.findIndex( + (event) => + event.type === "item.completed" && + event.payload.itemType === "assistant_message" && + String(event.itemId) === String(firstAssistantDelta.itemId), + ); + const toolStartedIndex = runtimeEvents.findIndex((event) => event.type === "item.started"); + const secondAssistantDeltaIndex = runtimeEvents.findIndex( + (event) => + event.type === "content.delta" && + event.payload.streamKind === "assistant_text" && + String(event.itemId) === String(secondAssistantDelta.itemId), + ); + + assert.equal( + firstAssistantCompletedIndex >= 0 && + toolStartedIndex >= 0 && + secondAssistantDeltaIndex >= 0 && + firstAssistantCompletedIndex < toolStartedIndex && + toolStartedIndex < secondAssistantDeltaIndex, + true, + ); + }).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* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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.deepEqual(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* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "approve this", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-approval-1", + uuid: "stream-approval-thread", + parent_tool_use_id: null, + event: { + type: "message_start", + message: { + id: "msg-approval-thread", + }, + }, + } as unknown as SDKMessage); + + const threadStarted = yield* Stream.runHead(adapter.streamEvents); + assert.equal(threadStarted._tag, "Some"); + if (threadStarted._tag !== "Some" || threadStarted.value.type !== "thread.started") { + return; + } + + 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; + } + assert.deepEqual(requested.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-use-1"), + }); + 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"); + assert.deepEqual(resolved.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-use-1"), + }); + + 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("classifies Agent tools and read-only Claude tools correctly for approvals", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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 agentPermissionPromise = canUseTool( + "Agent", + {}, + { + signal: new AbortController().signal, + toolUseID: "tool-agent-1", + }, + ); + + const agentRequested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(agentRequested._tag, "Some"); + if (agentRequested._tag !== "Some" || agentRequested.value.type !== "request.opened") { + return; + } + assert.equal(agentRequested.value.payload.requestType, "dynamic_tool_call"); + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(String(agentRequested.value.requestId)), + "accept", + ); + yield* Stream.runHead(adapter.streamEvents); + yield* Effect.promise(() => agentPermissionPromise); + + const grepPermissionPromise = canUseTool( + "Grep", + { pattern: "foo", path: "src" }, + { + signal: new AbortController().signal, + toolUseID: "tool-grep-approval-1", + }, + ); + + const grepRequested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(grepRequested._tag, "Some"); + if (grepRequested._tag !== "Some" || grepRequested.value.type !== "request.opened") { + return; + } + assert.equal(grepRequested.value.payload.requestType, "file_read_approval"); + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(String(grepRequested.value.requestId)), + "accept", + ); + yield* Stream.runHead(adapter.streamEvents); + yield* Effect.promise(() => grepPermissionPromise); + }).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* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: "claudeAgent", + 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: 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* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + 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("sets plan permission mode on sendTurn when interactionMode is plan", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this for me", + interactionMode: "plan", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("restores base permission mode on sendTurn when interactionMode is default", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + // First turn in plan mode + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + + // Complete the turn so we can send another + const turnCompletedFiber = 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-plan-restore", + uuid: "result-plan", + } as unknown as SDKMessage); + + yield* Fiber.join(turnCompletedFiber); + + // Second turn back to default + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); + + // First call sets "plan", second call restores "bypassPermissions" (the base for full-access) + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not call setPermissionMode when interactionMode is absent", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, []); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Start session in approval-required mode so canUseTool fires. + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "approval-required", + }); + + // Drain the session startup events (started, configured, state.changed). + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "question turn", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-user-input-1", + uuid: "stream-user-input-thread", + parent_tool_use_id: null, + event: { + type: "message_start", + message: { + id: "msg-user-input-thread", + }, + }, + } as unknown as SDKMessage); + + const threadStarted = yield* Stream.runHead(adapter.streamEvents); + assert.equal(threadStarted._tag, "Some"); + if (threadStarted._tag !== "Some" || threadStarted.value.type !== "thread.started") { + return; + } + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + // Simulate Claude calling AskUserQuestion with structured questions. + const askInput = { + questions: [ + { + question: "Which framework?", + header: "Framework", + options: [ + { label: "React", description: "React.js" }, + { label: "Vue", description: "Vue.js" }, + ], + multiSelect: false, + }, + ], + }; + + const permissionPromise = canUseTool("AskUserQuestion", askInput, { + signal: new AbortController().signal, + toolUseID: "tool-ask-1", + }); + + // The adapter should emit a user-input.requested event. + const requestedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requestedEvent._tag, "Some"); + if (requestedEvent._tag !== "Some") { + return; + } + assert.equal(requestedEvent.value.type, "user-input.requested"); + if (requestedEvent.value.type !== "user-input.requested") { + return; + } + const requestId = requestedEvent.value.requestId; + assert.equal(typeof requestId, "string"); + assert.equal(requestedEvent.value.payload.questions.length, 1); + assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); + assert.deepEqual(requestedEvent.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"), + }); + + // Respond with the user's answers. + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.makeUnsafe(requestId!), + { "Which framework?": "React" }, + ); + + // The adapter should emit a user-input.resolved event. + const resolvedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolvedEvent._tag, "Some"); + if (resolvedEvent._tag !== "Some") { + return; + } + assert.equal(resolvedEvent.value.type, "user-input.resolved"); + if (resolvedEvent.value.type !== "user-input.resolved") { + return; + } + assert.deepEqual(resolvedEvent.value.payload.answers, { + "Which framework?": "React", + }); + assert.deepEqual(resolvedEvent.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"), + }); + + // The canUseTool promise should resolve with the answers in SDK format. + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + const updatedInput = (permissionResult as { updatedInput: Record }) + .updatedInput; + assert.deepEqual(updatedInput.answers, { "Which framework?": "React" }); + // Original questions should be passed through. + assert.deepEqual(updatedInput.questions, askInput.questions); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("routes AskUserQuestion through user-input flow even in full-access mode", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // In full-access mode, regular tools are auto-approved. + // AskUserQuestion should still go through the user-input flow. + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + 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 askInput = { + questions: [ + { + question: "Deploy to which env?", + header: "Env", + options: [ + { label: "Staging", description: "Staging environment" }, + { label: "Production", description: "Production environment" }, + ], + multiSelect: false, + }, + ], + }; + + const permissionPromise = canUseTool("AskUserQuestion", askInput, { + signal: new AbortController().signal, + toolUseID: "tool-ask-2", + }); + + // Should still get user-input.requested even in full-access mode. + const requestedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requestedEvent._tag, "Some"); + if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") { + assert.fail("Expected user-input.requested event"); + return; + } + const requestId = requestedEvent.value.requestId; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.makeUnsafe(requestId!), + { "Deploy to which env?": "Staging" }, + ); + + // Drain the resolved event. + yield* Stream.runHead(adapter.streamEvents); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + const updatedInput = (permissionResult as { updatedInput: Record }) + .updatedInput; + assert.deepEqual(updatedInput.answers, { "Deploy to which env?": "Staging" }); + }).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 nativeThreadIds: Array = []; + const harness = makeHarness({ + nativeEventLogger: { + filePath: "memory://claude-native-events", + write: (event, threadId) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + nativeThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + const turn = 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 === "claudeAgent"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => + String( + (record.event as { readonly providerThreadId?: string } | undefined) + ?.providerThreadId, + ) === "sdk-session-native-log", + ), + true, + ); + assert.equal( + nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta", + ), + true, + ); + assert.equal( + nativeThreadIds.every((threadId) => threadId === String(THREAD_ID)), + true, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts new file mode 100644 index 0000000000..61c4d6cbcc --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -0,0 +1,2677 @@ +/** + * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. + * + * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic + * provider adapter contract and emits canonical runtime events. + * + * @module ClaudeAdapterLive + */ +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, + type ProviderUserInputAnswers, + type RuntimeContentStreamKind, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { + applyClaudePromptEffortPrefix, + getEffectiveClaudeCodeEffort, + getReasoningEffortOptions, + resolveReasoningEffortForProvider, + supportsClaudeFastMode, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, +} from "@t3tools/shared/model"; +import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "claudeAgent" as const; +type ClaudeTextStreamKind = Extract; +type ClaudeToolResultStreamKind = Extract< + RuntimeContentStreamKind, + "command_output" | "file_change_output" +>; + +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 startedAt: string; + readonly items: Array; + readonly assistantTextBlocks: Map; + readonly assistantTextBlockOrder: Array; + nextSyntheticAssistantBlockIndex: number; +} + +interface AssistantTextBlockState { + readonly itemId: string; + emittedTextDelta: boolean; + fallbackText: string; + streamClosed: boolean; + completionEmitted: boolean; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly suggestions?: ReadonlyArray; + readonly decision: Deferred.Deferred; +} + +interface PendingUserInput { + readonly questions: ReadonlyArray; + readonly answers: Deferred.Deferred; +} + +interface ToolInFlight { + readonly itemId: string; + readonly itemType: CanonicalItemType; + readonly toolName: string; + readonly title: string; + readonly detail?: string; + readonly input: Record; + readonly partialInputJson: string; + readonly lastEmittedInputFingerprint?: string; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly promptQueue: Queue.Queue; + readonly query: ClaudeQueryRuntime; + readonly startedAt: string; + readonly basePermissionMode: PermissionMode | undefined; + readonly bedrockOptions: BedrockOptions | undefined; + resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: 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 ClaudeAdapterLiveOptions { + 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; + } +} + +// ── Bedrock helpers ────────────────────────────────────────────────── + +interface BedrockOptions { + readonly useBedrock?: boolean | undefined; + readonly awsRegion?: string | undefined; + readonly awsProfile?: string | undefined; + readonly bedrockModelOverrideHaiku?: string | undefined; + readonly bedrockModelOverrideSonnet?: string | undefined; + readonly bedrockModelOverrideOpus?: string | undefined; +} + +function isBedrockActiveFromSettings(opts: BedrockOptions | undefined): boolean { + if (!opts) return false; + return Boolean( + opts.useBedrock || + opts.awsRegion || + opts.awsProfile || + opts.bedrockModelOverrideHaiku || + opts.bedrockModelOverrideSonnet || + opts.bedrockModelOverrideOpus, + ); +} + +function isBedrockActiveFromEnv(env: NodeJS.ProcessEnv): boolean { + return env.CLAUDE_CODE_USE_BEDROCK === "1"; +} + +function bedrockOptionsFromEnv(env: NodeJS.ProcessEnv): BedrockOptions { + return { + useBedrock: true, + awsRegion: env.AWS_REGION || env.AWS_DEFAULT_REGION || undefined, + awsProfile: env.AWS_PROFILE || undefined, + bedrockModelOverrideHaiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, + bedrockModelOverrideSonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, + bedrockModelOverrideOpus: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, + }; +} + +function resolveBedrockModel( + modelSlug: string | undefined, + opts: BedrockOptions, +): string | undefined { + if (!modelSlug) return opts.bedrockModelOverrideSonnet ?? opts.bedrockModelOverrideOpus; + const slug = modelSlug.toLowerCase(); + if (slug.includes("haiku") && opts.bedrockModelOverrideHaiku) return opts.bedrockModelOverrideHaiku; + if (slug.includes("opus") && opts.bedrockModelOverrideOpus) return opts.bedrockModelOverrideOpus; + if (opts.bedrockModelOverrideSonnet) return opts.bedrockModelOverrideSonnet; + if (opts.bedrockModelOverrideOpus) return opts.bedrockModelOverrideOpus; + return undefined; +} + +function buildBedrockEnv( + baseEnv: NodeJS.ProcessEnv, + opts: BedrockOptions, + resolvedModelArn: string | undefined, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv }; + env.CLAUDE_CODE_USE_BEDROCK = "1"; + if (opts.awsRegion) { + env.AWS_REGION = opts.awsRegion; + env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION = opts.awsRegion; + } + if (opts.awsProfile) { + env.AWS_PROFILE = opts.awsProfile; + } + if (opts.bedrockModelOverrideHaiku) { + env.ANTHROPIC_DEFAULT_HAIKU_MODEL = opts.bedrockModelOverrideHaiku; + } + if (opts.bedrockModelOverrideSonnet) { + env.ANTHROPIC_DEFAULT_SONNET_MODEL = opts.bedrockModelOverrideSonnet; + } + if (opts.bedrockModelOverrideOpus) { + env.ANTHROPIC_DEFAULT_OPUS_MODEL = opts.bedrockModelOverrideOpus; + } + if (resolvedModelArn) { + env.ANTHROPIC_MODEL = resolvedModelArn; + } + return env; +} + +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("agent")) { + return "collab_agent_tool_call"; + } + if ( + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") + ) { + return "collab_agent_tool_call"; + } + 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"; + } + if (normalized.includes("websearch") || normalized.includes("web search")) { + return "web_search"; + } + if (normalized.includes("image")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +function isReadOnlyToolName(toolName: string): boolean { + const normalized = toolName.toLowerCase(); + return ( + normalized === "read" || + normalized.includes("read file") || + normalized.includes("view") || + normalized.includes("grep") || + normalized.includes("glob") || + normalized.includes("search") + ); +} + +function classifyRequestType(toolName: string): CanonicalRequestType { + if (isReadOnlyToolName(toolName)) { + return "file_read_approval"; + } + const itemType = classifyToolItemType(toolName); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : "dynamic_tool_call"; +} + +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 "collab_agent_tool_call": + return "Subagent task"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + 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 requestedEffort = resolveReasoningEffortForProvider( + "claudeAgent", + input.modelOptions?.claudeAgent?.effort ?? null, + ); + const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); + const promptEffort = + requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model) + ? "ultrathink" + : requestedEffort && supportedEffortOptions.includes(requestedEffort) + ? requestedEffort + : null; + const text = applyClaudePromptEffortPrefix(fragments.join("\n\n"), promptEffort); + + 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): ClaudeTextStreamKind { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + +function nativeProviderRefs( + _context: ClaudeSessionContext, + options?: { + readonly providerItemId?: string | undefined; + }, +): NonNullable { + if (options?.providerItemId) { + return { + providerItemId: ProviderItemId.makeUnsafe(options.providerItemId), + }; + } + return {}; +} + +function extractAssistantTextBlocks(message: SDKMessage): Array { + 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; +} + +function extractContentBlockText(block: unknown): string { + if (!block || typeof block !== "object") { + return ""; + } + + const candidate = block as { type?: unknown; text?: unknown }; + return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : ""; +} + +function extractTextContent(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => extractTextContent(entry)).join(""); + } + + if (!value || typeof value !== "object") { + return ""; + } + + const record = value as { + text?: unknown; + content?: unknown; + }; + + if (typeof record.text === "string") { + return record.text; + } + + return extractTextContent(record.content); +} + +function tryParseJsonRecord(value: string): Record | undefined { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function toolInputFingerprint(input: Record): string | undefined { + try { + return JSON.stringify(input); + } catch { + return undefined; + } +} + +function toolResultStreamKind(itemType: CanonicalItemType): ClaudeToolResultStreamKind | undefined { + switch (itemType) { + case "command_execution": + return "command_output"; + case "file_change": + return "file_change_output"; + default: + return undefined; + } +} + +function toolResultBlocksFromUserMessage(message: SDKMessage): Array<{ + readonly toolUseId: string; + readonly block: Record; + readonly text: string; + readonly isError: boolean; +}> { + if (message.type !== "user") { + return []; + } + + const content = (message.message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) { + return []; + } + + const blocks: Array<{ + readonly toolUseId: string; + readonly block: Record; + readonly text: string; + readonly isError: boolean; + }> = []; + + for (const entry of content) { + if (!entry || typeof entry !== "object") { + continue; + } + + const block = entry as Record; + if (block.type !== "tool_result") { + continue; + } + + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined; + if (!toolUseId) { + continue; + } + + blocks.push({ + toolUseId, + block, + text: extractTextContent(block.content), + isError: block.is_error === true, + }); + } + + return blocks; +} + +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 === "user") { + return toolResultBlocksFromUserMessage(message)[0]?.toolUseId; + } + + 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 makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { + 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, + }, + }, + context.session.threadId, + ); + }); + + 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 ensureAssistantTextBlock = ( + context: ClaudeSessionContext, + blockIndex: number, + options?: { + readonly fallbackText?: string; + readonly streamClosed?: boolean; + }, + ): Effect.Effect< + | { + readonly blockIndex: number; + readonly block: AssistantTextBlockState; + } + | undefined + > => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return undefined; + } + + const existing = turnState.assistantTextBlocks.get(blockIndex); + if (existing) { + if (existing.fallbackText.length === 0 && options?.fallbackText) { + existing.fallbackText = options.fallbackText; + } + if (options?.streamClosed) { + existing.streamClosed = true; + } + return { blockIndex, block: existing }; + } + + const block: AssistantTextBlockState = { + itemId: yield* Random.nextUUIDv4, + emittedTextDelta: false, + fallbackText: options?.fallbackText ?? "", + streamClosed: options?.streamClosed ?? false, + completionEmitted: false, + }; + turnState.assistantTextBlocks.set(blockIndex, block); + turnState.assistantTextBlockOrder.push(blockIndex); + return { blockIndex, block }; + }); + + const createSyntheticAssistantTextBlock = ( + context: ClaudeSessionContext, + fallbackText: string, + ): Effect.Effect< + | { + readonly blockIndex: number; + readonly block: AssistantTextBlockState; + } + | undefined + > => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return undefined; + } + + const blockIndex = turnState.nextSyntheticAssistantBlockIndex; + turnState.nextSyntheticAssistantBlockIndex -= 1; + return yield* ensureAssistantTextBlock(context, blockIndex, { + fallbackText, + streamClosed: true, + }); + }); + + const completeAssistantTextBlock = ( + context: ClaudeSessionContext, + blockIndex: number, + options?: { + readonly force?: boolean; + readonly rawMethod?: string; + readonly rawPayload?: unknown; + }, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const block = turnState?.assistantTextBlocks.get(blockIndex); + if (!turnState || !block || block.completionEmitted) { + return; + } + + if (!options?.force && !block.streamClosed) { + return; + } + + if (!block.emittedTextDelta && block.fallbackText.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(block.itemId), + payload: { + streamKind: "assistant_text", + delta: block.fallbackText, + }, + providerRefs: nativeProviderRefs(context), + ...(options?.rawMethod || options?.rawPayload + ? { + raw: { + source: "claude.sdk.message" as const, + ...(options.rawMethod ? { method: options.rawMethod } : {}), + payload: options?.rawPayload, + }, + } + : {}), + }); + } + + block.completionEmitted = true; + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(block.itemId), + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(block.fallbackText.length > 0 ? { detail: block.fallbackText } : {}), + }, + providerRefs: nativeProviderRefs(context), + ...(options?.rawMethod || options?.rawPayload + ? { + raw: { + source: "claude.sdk.message" as const, + ...(options.rawMethod ? { method: options.rawMethod } : {}), + payload: options?.rawPayload, + }, + } + : {}), + }); + }); + + const backfillAssistantTextBlocksFromSnapshot = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return; + } + + const snapshotTextBlocks = extractAssistantTextBlocks(message); + if (snapshotTextBlocks.length === 0) { + return; + } + + const orderedBlocks = turnState.assistantTextBlockOrder + .map((blockIndex) => ({ + blockIndex, + block: turnState.assistantTextBlocks.get(blockIndex), + })) + .filter( + ( + entry, + ): entry is { + readonly blockIndex: number; + readonly block: AssistantTextBlockState; + } => entry.block !== undefined, + ); + + for (const [position, text] of snapshotTextBlocks.entries()) { + const existingEntry = orderedBlocks[position]; + const entry = + existingEntry ?? + (yield* createSyntheticAssistantTextBlock(context, text).pipe( + Effect.map((created) => { + if (!created) { + return undefined; + } + orderedBlocks.push(created); + return created; + }), + )); + if (!entry) { + continue; + } + + if (entry.block.fallbackText.length === 0) { + entry.block.fallbackText = text; + } + + if (entry.block.streamClosed && !entry.block.completionEmitted) { + yield* completeAssistantTextBlock(context, entry.blockIndex, { + rawMethod: "claude/assistant", + rawPayload: message, + }); + } + } + }); + + 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: nativeProviderRefs(context), + }); + }); + + 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: nativeProviderRefs(context), + }); + }); + + 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; + } + + for (const [index, tool] of context.inFlightTools.entries()) { + const toolStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: toolStamp.eventId, + provider: PROVIDER, + createdAt: toolStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: status === "completed" ? "completed" : "failed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: tool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/result", + payload: result ?? { status }, + }, + }); + context.inFlightTools.delete(index); + } + // Clear any remaining stale entries (e.g. from interrupted content blocks) + context.inFlightTools.clear(); + + for (const blockIndex of turnState.assistantTextBlockOrder) { + yield* completeAssistantTextBlock(context, blockIndex, { + force: true, + rawMethod: "claude/result", + rawPayload: result ?? { status }, + }); + } + + 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: nativeProviderRefs(context), + }); + + 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.type === "thinking_delta") && + context.turnState + ) { + const deltaText = + event.delta.type === "text_delta" + ? event.delta.text + : typeof event.delta.thinking === "string" + ? event.delta.thinking + : ""; + if (deltaText.length === 0) { + return; + } + const streamKind = streamKindFromDeltaType(event.delta.type); + const assistantBlockEntry = + event.delta.type === "text_delta" + ? yield* ensureAssistantTextBlock(context, event.index) + : context.turnState.assistantTextBlocks.get(event.index) + ? { + blockIndex: event.index, + block: context.turnState.assistantTextBlocks.get( + event.index, + ) as AssistantTextBlockState, + } + : undefined; + if (assistantBlockEntry?.block && event.delta.type === "text_delta") { + assistantBlockEntry.block.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, + ...(assistantBlockEntry?.block + ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + : {}), + payload: { + streamKind, + delta: deltaText, + }, + providerRefs: nativeProviderRefs(context), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + return; + } + + if (event.delta.type === "input_json_delta") { + const tool = context.inFlightTools.get(event.index); + if (!tool || typeof event.delta.partial_json !== "string") { + return; + } + + const partialInputJson = tool.partialInputJson + event.delta.partial_json; + const parsedInput = tryParseJsonRecord(partialInputJson); + const detail = parsedInput + ? summarizeToolRequest(tool.toolName, parsedInput) + : tool.detail; + let nextTool: ToolInFlight = { + ...tool, + partialInputJson, + ...(parsedInput ? { input: parsedInput } : {}), + ...(detail ? { detail } : {}), + }; + + const nextFingerprint = + parsedInput && Object.keys(parsedInput).length > 0 + ? toolInputFingerprint(parsedInput) + : undefined; + context.inFlightTools.set(event.index, nextTool); + + if ( + !parsedInput || + !nextFingerprint || + tool.lastEmittedInputFingerprint === nextFingerprint + ) { + return; + } + + nextTool = { + ...nextTool, + lastEmittedInputFingerprint: nextFingerprint, + }; + context.inFlightTools.set(event.index, nextTool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(nextTool.itemId), + payload: { + itemType: nextTool.itemType, + status: "inProgress", + title: nextTool.title, + ...(nextTool.detail ? { detail: nextTool.detail } : {}), + data: { + toolName: nextTool.toolName, + input: nextTool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta/input_json_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if (block.type === "text") { + yield* ensureAssistantTextBlock(context, index, { + fallbackText: extractContentBlockText(block), + }); + return; + } + 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 inputFingerprint = + Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + input: toolInput, + partialInputJson: "", + ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), + }; + 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: nativeProviderRefs(context, { providerItemId: 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 assistantBlock = context.turnState?.assistantTextBlocks.get(index); + if (assistantBlock) { + assistantBlock.streamClosed = true; + yield* completeAssistantTextBlock(context, index, { + rawMethod: "claude/stream_event/content_block_stop", + rawPayload: message, + }); + return; + } + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + } + }); + + const handleUserMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "user") { + return; + } + + if (context.turnState) { + context.turnState.items.push(message.message); + } + + for (const toolResult of toolResultBlocksFromUserMessage(message)) { + const toolEntry = Array.from(context.inFlightTools.entries()).find( + ([, tool]) => tool.itemId === toolResult.toolUseId, + ); + if (!toolEntry) { + continue; + } + + const [index, tool] = toolEntry; + const itemStatus = toolResult.isError ? "failed" : "completed"; + const toolData = { + toolName: tool.toolName, + input: tool.input, + result: toolResult.block, + }; + + const updatedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: updatedStamp.eventId, + provider: PROVIDER, + createdAt: updatedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: toolResult.isError ? "failed" : "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + + const streamKind = toolResultStreamKind(tool.itemType); + if (streamKind && toolResult.text.length > 0 && context.turnState) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + streamKind, + delta: toolResult.text, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + } + + const completedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: completedStamp.eventId, + provider: PROVIDER, + createdAt: completedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: itemStatus, + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + + context.inFlightTools.delete(index); + } + }); + + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } + + // Auto-start a synthetic turn for assistant messages that arrive without + // an active turn (e.g., background agent/subagent responses between user prompts). + if (!context.turnState) { + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const startedAt = yield* nowIso; + context.turnState = { + turnId, + startedAt, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + nextSyntheticAssistantBlockIndex: -1, + }; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: startedAt, + }; + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: {}, + providerRefs: { + ...nativeProviderRefs(context), + providerTurnId: turnId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/synthetic-turn-start", + payload: {}, + }, + }); + } + + if (context.turnState) { + context.turnState.items.push(message.message); + yield* backfillAssistantTextBlocksFromSnapshot(context, 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: nativeProviderRefs(context), + 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.summary ? { summary: message.summary } : {}), + ...(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: nativeProviderRefs(context), + 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": + yield* handleUserMessage(context, message); + 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: nativeProviderRefs(context), + }); + } + 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: ClaudeAdapterShape["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 pendingUserInputs = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + /** + * Handle AskUserQuestion tool calls by emitting a `user-input.requested` + * runtime event and waiting for the user to respond via `respondToUserInput`. + */ + const handleAskUserQuestion = ( + context: ClaudeSessionContext, + toolInput: Record, + callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + ) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + + // Parse questions from the SDK's AskUserQuestion input. + const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : []; + const questions: Array = rawQuestions.map( + (q: Record, idx: number) => ({ + id: typeof q.header === "string" ? q.header : `q-${idx}`, + header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, + question: typeof q.question === "string" ? q.question : "", + options: Array.isArray(q.options) + ? q.options.map((opt: Record) => ({ + label: typeof opt.label === "string" ? opt.label : "", + description: typeof opt.description === "string" ? opt.description : "", + })) + : [], + multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : false, + }), + ); + + const answersDeferred = yield* Deferred.make(); + const pendingInput: PendingUserInput = { + questions, + answers: answersDeferred, + }; + + // Emit user-input.requested so the UI can present the questions. + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.requested", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { questions }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion", + payload: { toolName: "AskUserQuestion", input: toolInput }, + }, + }); + + pendingUserInputs.set(requestId, pendingInput); + + // Handle abort (e.g. turn interrupted while waiting for user input). + const onAbort = () => { + if (!pendingUserInputs.has(requestId)) { + return; + } + pendingUserInputs.delete(requestId); + Effect.runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); + }; + callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + + // Block until the user provides answers. + const answers = yield* Deferred.await(answersDeferred); + pendingUserInputs.delete(requestId); + + // Emit user-input.resolved so the UI knows the interaction completed. + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { answers }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion/resolved", + payload: { answers }, + }, + }); + + // Return the answers to the SDK in the expected format: + // { questions: [...], answers: { questionText: selectedLabel } } + return { + behavior: "allow", + updatedInput: { + questions: toolInput.questions, + answers, + }, + } satisfies PermissionResult; + }); + + 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; + } + + // Handle AskUserQuestion: surface clarifying questions to the + // user via the user-input runtime event channel, regardless of + // runtime mode (plan mode relies on this heavily). + if (toolName === "AskUserQuestion") { + return yield* handleAskUserQuestion(context, toolInput, callbackOptions); + } + + 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: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + 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: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + 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?.claudeAgent; + const bedrockFromSettings = isBedrockActiveFromSettings(providerOptions); + const bedrockFromEnv = isBedrockActiveFromEnv(process.env); + const bedrock = bedrockFromSettings || bedrockFromEnv; + const effectiveBedrockOpts: BedrockOptions | undefined = bedrockFromSettings + ? providerOptions! + : bedrockFromEnv + ? bedrockOptionsFromEnv(process.env) + : undefined; + const resolvedModelArn = bedrock + ? resolveBedrockModel(input.model, effectiveBedrockOpts!) + : undefined; + const sessionModel = bedrock ? resolvedModelArn : input.model; + const sessionEnv = bedrockFromSettings + ? buildBedrockEnv(process.env, providerOptions!, resolvedModelArn) + : process.env; + const requestedEffort = resolveReasoningEffortForProvider( + "claudeAgent", + input.modelOptions?.claudeAgent?.effort ?? null, + ); + const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); + const effort = + requestedEffort && supportedEffortOptions.includes(requestedEffort) + ? requestedEffort + : null; + const fastMode = + input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model); + const thinking = + typeof input.modelOptions?.claudeAgent?.thinking === "boolean" && + supportsClaudeThinkingToggle(input.model) + ? input.modelOptions.claudeAgent.thinking + : undefined; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + const settings = { + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(sessionModel ? { model: sessionModel } : {}), + ...(providerOptions?.binaryPath + ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } + : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + includePartialMessages: true, + canUseTool, + env: sessionEnv, + ...(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, + basePermissionMode: permissionMode, + bedrockOptions: effectiveBedrockOpts, + resumeSessionId: resumeState?.resume, + pendingApprovals, + pendingUserInputs, + 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 } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(fastMode ? { fastMode: true } : {}), + }, + }, + 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: ClaudeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + // Auto-close a stale synthetic turn (from background agent responses + // between user prompts) to prevent blocking the user's next turn. + yield* completeTurn(context, "completed"); + } + + if (input.model) { + const turnModel = context.bedrockOptions + ? resolveBedrockModel(input.model, context.bedrockOptions) + : input.model; + if (turnModel) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(turnModel), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + } + + // Apply interaction mode by switching the SDK's permission mode. + // "plan" maps directly to the SDK's "plan" permission mode; + // "default" restores the session's original permission mode. + // When interactionMode is absent we leave the current mode unchanged. + if (input.interactionMode === "plan") { + yield* Effect.tryPromise({ + try: () => context.query.setPermissionMode("plan"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + } else if (input.interactionMode === "default") { + yield* Effect.tryPromise({ + try: () => + context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + startedAt: yield* nowIso, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + nextSyntheticAssistantBlockIndex: -1, + }; + + 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: {}, + }); + + 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: ClaudeAdapterShape["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: ClaudeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); + + const rollbackThread: ClaudeAdapterShape["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: ClaudeAdapterShape["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: ClaudeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/respondToUserInput", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + context.pendingUserInputs.delete(requestId); + yield* Deferred.succeed(pending.answers, answers); + }); + + const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: ClaudeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const stopAll: ClaudeAdapterShape["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 ClaudeAdapterShape; + }); +} + +export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); + +export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { + return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts b/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts new file mode 100644 index 0000000000..775de0a97b --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts @@ -0,0 +1,149 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; +import { afterEach, describe, expect, it } from "vitest"; + +async function* emptyPrompt(): AsyncGenerator {} + +class FakeClaudeCodeProcess implements SpawnedProcess { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + killed = false; + exitCode: number | null = null; + + private readonly events = new EventEmitter(); + private bufferedInput = ""; + + constructor( + private readonly onMessage: ( + message: Record, + process: FakeClaudeCodeProcess, + ) => void, + ) { + this.stdin.setEncoding("utf8"); + this.stdin.on("data", (chunk: string) => { + this.bufferedInput += chunk; + this.drainInput(); + }); + } + + emitJson(message: unknown): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } + + kill(_signal: NodeJS.Signals): boolean { + this.killed = true; + this.exitCode = 0; + this.stdout.end(); + this.events.emit("exit", 0, null); + return true; + } + + on( + event: "exit" | "error", + listener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.on(event, listener); + } + + once( + event: "exit" | "error", + listener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.once(event, listener); + } + + off( + event: "exit" | "error", + listener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.off(event, listener); + } + + private drainInput(): void { + while (true) { + const newlineIndex = this.bufferedInput.indexOf("\n"); + if (newlineIndex === -1) { + return; + } + const line = this.bufferedInput.slice(0, newlineIndex).trim(); + this.bufferedInput = this.bufferedInput.slice(newlineIndex + 1); + if (line.length === 0) { + continue; + } + this.onMessage(JSON.parse(line) as Record, this); + } + } +} + +describe("Claude SDK fast mode probe", () => { + let activeQuery: ReturnType | null = null; + + afterEach(() => { + activeQuery?.close(); + activeQuery = null; + }); + + it("passes fast mode through the SDK settings flag", async () => { + let spawnOptions: SpawnOptions | undefined; + + activeQuery = query({ + prompt: emptyPrompt(), + options: { + persistSession: false, + settings: { + fastMode: true, + }, + spawnClaudeCodeProcess: (options): SpawnedProcess => { + spawnOptions = options; + return new FakeClaudeCodeProcess((message, process) => { + if ( + message.type !== "control_request" || + typeof message.request_id !== "string" || + !message.request || + typeof message.request !== "object" || + (message.request as { subtype?: unknown }).subtype !== "initialize" + ) { + return; + } + + process.emitJson({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + commands: [], + agents: [], + output_style: "default", + available_output_styles: ["default"], + models: [], + account: { + subscriptionType: "max", + }, + fast_mode_state: "on", + }, + }, + }); + }); + }, + }, + }); + + const initialization = await activeQuery.initializationResult(); + expect(initialization.fast_mode_state).toBe("on"); + + expect(spawnOptions).toBeDefined(); + const settingsFlagIndex = spawnOptions?.args.indexOf("--settings") ?? -1; + expect(settingsFlagIndex).toBeGreaterThan(-1); + expect(JSON.parse(spawnOptions?.args[settingsFlagIndex + 1] ?? "")).toEqual({ + fastMode: true, + }); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ad206d0be..a402fb4c71 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -22,6 +22,7 @@ import { type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; +import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; @@ -156,6 +157,29 @@ const validationLayer = it.layer( ); validationLayer("CodexAdapterLive validation", (it) => { + it.effect("returns validation error for non-codex provider on startSession", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .startSession({ + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "Expected provider 'codex' but received 'claudeAgent'.", + }), + ); + assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); + }), + ); it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { validationManager.startSessionImpl.mockClear(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08c..db0293f0fe 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 { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.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: ClaudeAdapterShape = { + provider: "claudeAgent", + capabilities: { sessionModelSwitch: "in-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(ClaudeAdapter, 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("claudeAgent"); assert.equal(codex, fakeCodexAdapter); + assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeAgent"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed7907..23ef8d1b9b 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -1,7 +1,7 @@ /** * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. * - * Binds provider kinds (codex/cursor/...) to concrete adapter services. + * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services. * This layer only performs adapter lookup; it does not route session-scoped * calls or own provider lifecycle workflows. * @@ -15,6 +15,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.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* ClaudeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 10bd12a7cf..e24f07bcfa 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -5,9 +5,11 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + checkClaudeProviderStatus, checkCodexProviderStatus, hasCustomModelProvider, parseAuthStatusFromOutput, + parseClaudeAuthStatusFromOutput, readCodexConfigModelProvider, } from "./ProviderHealth"; @@ -464,4 +466,175 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { }), ); }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { stdout: "", stderr: "Something went wrong", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth status reports not logged in", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when output includes 'not logged in'", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when auth status command is unsupported", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus; + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent authentication status command is unavailable in this version of Claude.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── + + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=true is authenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=false is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"ok":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1fed0597a2..cbb97a807e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -26,6 +26,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; +const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -44,12 +45,7 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { function isCommandMissingCause(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("enoent") || - lower.includes("notfound") - ); + return lower.includes("enoent") || lower.includes("notfound"); } function detailFromResult( @@ -264,6 +260,27 @@ const runCodexCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runClaudeCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("claude", [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + // ── Health check ──────────────────────────────────────────────────── export const checkCodexProviderStatus: Effect.Effect< @@ -390,18 +407,197 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +// ── Claude Agent health check ─────────────────────────────────────── + +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 Agent authentication status command is unavailable in this version of Claude.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `claude login`") || + lowerOutput.includes("run claude login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude is not authenticated. Run `claude auth login` and try again.", + }; + } + + // `claude auth status` returns JSON with a `loggedIn` boolean. + 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 is not authenticated. Run `claude auth login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude 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 authentication status. ${detail}` + : "Could not verify Claude authentication status.", + }; +} + +export const checkClaudeProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + // Probe 1: `claude --version` — is the CLI reachable? + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Claude Agent CLI 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_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Claude Agent CLI is installed but failed to run. ${detail}` + : "Claude Agent CLI is installed but failed to run.", + }; + } + + // Probe 2: `claude auth status` — is the user authenticated? + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", + }; + } + + if (Option.isNone(authProbe.success)) { + return { + provider: CLAUDE_AGENT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Claude authentication status. Timed out while running command.", + }; + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return { + provider: CLAUDE_AGENT_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 statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { + concurrency: "unbounded", + }).pipe(Effect.forkScoped); return { - getStatuses: Fiber.join(codexStatusFiber), + getStatuses: Fiber.join(statusesFiber), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d5cf4424b1..c8fd5d138b 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: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -74,7 +74,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { status: "ready", runtimeMode: input.runtimeMode, threadId: input.threadId, - resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` }, + resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -217,12 +217,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeAgent"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeAgent" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeAgent"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -247,6 +250,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -533,6 +537,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeAgent", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeAgent"); + 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, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -573,6 +600,57 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), { + provider: "claudeAgent", + threadId: asThreadId("thread-claude-send-turn"), + cwd: "/tmp/project-claude-send-turn", + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, + 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; + modelOptions?: unknown; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.deepEqual(startPayload.modelOptions, { + claudeAgent: { + effort: "max", + }, + }); + 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; @@ -589,6 +667,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }); yield* routing.codex.stopAll(); + yield* routing.claude.stopAll(); const remaining = yield* provider.listSessions(); assert.equal(remaining.length, 0); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e3bc72041..0a250f965a 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/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0e..d23b247f21 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -141,8 +141,8 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* runtimeRepository.upsert({ threadId, - providerName: "cursor", - adapterKey: "cursor", + providerName: "claudeAgent", + adapterKey: "claudeAgent", runtimeMode: "full-access", status: "running", lastSeenAt: new Date().toISOString(), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 38e097e1c9..961c63d696 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 === "claudeAgent") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/ClaudeAdapter.ts b/apps/server/src/provider/Services/ClaudeAdapter.ts new file mode 100644 index 0000000000..3a3f616ea5 --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeAdapter.ts @@ -0,0 +1,30 @@ +/** + * ClaudeAdapter - Claude Agent 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: "claudeAgent"` context. + * + * @module ClaudeAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClaudeAdapterShape - Service API for the Claude Agent provider adapter. + */ +export interface ClaudeAdapterShape extends ProviderAdapterShape { + readonly provider: "claudeAgent"; +} + +/** + * ClaudeAdapter - Service tag for Claude Agent provider adapter operations. + */ +export class ClaudeAdapter extends ServiceMap.Service()( + "t3/provider/Services/ClaudeAdapter", +) {} diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 318d7e18d0..ec3b2d318d 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/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96f..4486920dae 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 { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; 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 = makeClaudeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..fbc5d54cf0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -875,6 +875,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< issues: keybindingsConfig.issues, providers: providerStatuses, availableEditors, + ...(process.env.CLAUDE_CODE_USE_BEDROCK === "1" + ? { + bedrockEnvironment: { + detected: true, + ...(process.env.AWS_REGION ? { awsRegion: process.env.AWS_REGION } : {}), + ...(process.env.ANTHROPIC_MODEL + ? { anthropicModel: process.env.ANTHROPIC_MODEL } + : {}), + ...(process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + ? { anthropicDefaultHaikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } + : {}), + ...(process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + ? { anthropicDefaultSonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } + : {}), + ...(process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + ? { anthropicDefaultOpusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } + : {}), + }, + } + : {}), }; case WS_METHODS.serverUpsertKeybinding: { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 15579809a4..6ef0e58a25 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -20,6 +20,13 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("normalizes provider-specific aliases for claude", () => { + expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); + expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ + "claude/custom-sonnet", + ]); + }); }); describe("getAppModelOptions", () => { @@ -46,6 +53,13 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + it("keeps a saved custom provider model available as an exact slug option", () => { + const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); + + expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( + true, + ); + }); }); describe("resolveAppModelSelection", () => { @@ -65,3 +79,11 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +describe("provider-specific custom models", () => { + it("includes provider-specific custom slugs in non-codex model lists", () => { + const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); + + expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d060c2ef06..1e51f7fec2 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)), + claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -34,7 +35,28 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), textGenerationModel: Schema.optional(TrimmedNonEmptyString), + claudeUseBedrock: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + claudeAwsRegion: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeAwsProfile: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeBedrockArnHaiku: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeBedrockArnSonnet: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeBedrockArnOpus: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -74,6 +96,13 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +function normalizeAppSettings(settings: AppSettings): AppSettings { + return { + ...settings, + customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), + }; +} export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -152,10 +181,7 @@ export function useAppSettings() { const updateSettings = useCallback( (patch: Partial) => { - setSettings((prev) => ({ - ...prev, - ...patch, - })); + setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); }, [setSettings], ); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9b90567590..80567927b3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -123,9 +123,11 @@ export function cloneComposerImageForRetry( export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 77cdb0ea19..76b34b4cad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,32 +1,38 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - type EditorId, - type KeybindingCommand, - type CodexReasoningEffort, + type ClaudeCodeEffort, type MessageId, - type ProjectId, - type ProjectEntry, type ProjectScript, type ModelSlug, + type ProviderKind, + type ProjectEntry, + type ProjectId, + type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, - type ProviderApprovalDecision, type ServerProviderStatus, - type ProviderKind, type ThreadId, type TurnId, + type EditorId, + type KeybindingCommand, OrchestrationThreadActivity, - RuntimeMode, ProviderInteractionMode, + RuntimeMode, } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, normalizeModelSlug, + resolveReasoningEffortForProvider, resolveModelSlugForProvider, + supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -145,7 +151,8 @@ 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 { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; +import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; @@ -180,6 +187,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: string | null; + text: string; +}): string { + if (params.provider === "claudeAgent" && params.effort === "ultrathink") { + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); + } + return params.text; +} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -254,8 +272,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); - const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); - const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -578,7 +594,14 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsByProvider = useMemo( + () => ({ + codex: settings.customCodexModels, + claudeAgent: settings.customClaudeModels, + }), + [settings.customClaudeModels, settings.customCodexModels], + ); + const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -590,32 +613,98 @@ export default function ChatView({ threadId }: ChatViewProps) { draftModel, ) as ModelSlug; }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); - const reasoningOptions = getReasoningEffortOptions(selectedProvider); - const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); - const selectedCodexFastModeEnabled = - selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const draftModelOptions = composerDraft.modelOptions; + const selectedCodexEffort = + selectedProvider === "codex" + ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ?? + getDefaultReasoningEffort("codex")) + : null; + const selectedClaudeReasoningOptions = + selectedProvider === "claudeAgent" + ? getReasoningEffortOptions("claudeAgent", selectedModel) + : ([] as const); + const selectedClaudeBaseEffort = + selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0 + ? (() => { + const draftEffort = resolveReasoningEffortForProvider( + "claudeAgent", + draftModelOptions?.claudeAgent?.effort, + ); + if ( + draftEffort && + draftEffort !== "ultrathink" && + selectedClaudeReasoningOptions.includes(draftEffort) + ) { + return draftEffort; + } + const defaultEffort = getDefaultReasoningEffort("claudeAgent"); + return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null; + })() + : null; + const isClaudeUltrathink = + selectedProvider === "claudeAgent" && + supportsClaudeUltrathinkKeyword(selectedModel) && + isClaudeUltrathinkPrompt(prompt); + const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort; const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider !== "codex") { - return undefined; + if (selectedProvider === "codex") { + const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); + return codexOptions ? { 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]); - const providerOptionsForDispatch = useMemo(() => { - if (!settings.codexBinaryPath && !settings.codexHomePath) { - return undefined; + if (selectedProvider === "claudeAgent") { + const claudeOptions = normalizeClaudeModelOptions( + selectedModel, + draftModelOptions?.claudeAgent, + ); + return claudeOptions ? { claudeAgent: claudeOptions } : undefined; } + return undefined; + }, [draftModelOptions, selectedModel, selectedProvider]); + const providerOptionsForDispatch = useMemo(() => { + const codex = + settings.codexBinaryPath || settings.codexHomePath + ? { + ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + } + : undefined; + const claudeAgent = + settings.claudeUseBedrock || + settings.claudeAwsRegion || + settings.claudeAwsProfile || + settings.claudeBedrockArnHaiku || + settings.claudeBedrockArnSonnet || + settings.claudeBedrockArnOpus + ? { + ...(settings.claudeUseBedrock ? { useBedrock: true } : {}), + ...(settings.claudeAwsRegion ? { awsRegion: settings.claudeAwsRegion } : {}), + ...(settings.claudeAwsProfile ? { awsProfile: settings.claudeAwsProfile } : {}), + ...(settings.claudeBedrockArnHaiku + ? { bedrockModelOverrideHaiku: settings.claudeBedrockArnHaiku } + : {}), + ...(settings.claudeBedrockArnSonnet + ? { bedrockModelOverrideSonnet: settings.claudeBedrockArnSonnet } + : {}), + ...(settings.claudeBedrockArnOpus + ? { bedrockModelOverrideOpus: settings.claudeBedrockArnOpus } + : {}), + } + : undefined; + if (!codex && !claudeAgent) return undefined; return { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, + ...(codex ? { codex } : {}), + ...(claudeAgent ? { claudeAgent } : {}), }; - }, [settings.codexBinaryPath, settings.codexHomePath]); + }, [ + settings.codexBinaryPath, + settings.codexHomePath, + settings.claudeUseBedrock, + settings.claudeAwsRegion, + settings.claudeAwsProfile, + settings.claudeBedrockArnHaiku, + settings.claudeBedrockArnSonnet, + settings.claudeBedrockArnOpus, + ]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2413,6 +2502,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedPromptEffort, + text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2435,7 +2529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: messageTextForSend, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2458,6 +2552,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; + setComposerDraftProvider(threadIdForSend, selectedProvider); + if (selectedModel) { + setComposerDraftModel(threadIdForSend, selectedModel); + } clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2512,7 +2610,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER[selectedProvider]; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2586,7 +2684,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: outgoingMessageText, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2829,6 +2927,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: selectedPromptEffort, + text: trimmed, + }); sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2838,7 +2941,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2866,7 +2969,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -2910,6 +3013,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2939,12 +3043,17 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const outgoingImplementationPrompt = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedPromptEffort, + text: implementationPrompt, + }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || (activeThread.model as ModelSlug) || (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + DEFAULT_MODEL_BY_PROVIDER[selectedProvider]; sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2975,7 +3084,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -3033,6 +3142,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -3051,32 +3161,34 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection(provider, customModelsByProvider[provider], model), ); scheduleComposerFocus(); }, [ activeThread, + customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, ], ); - const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { - setComposerDraftEffort(threadId, effort); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], - ); - const onCodexFastModeChange = useCallback( - (enabled: boolean) => { - setComposerDraftCodexFastMode(threadId, enabled); + const setPromptFromTraits = useCallback( + (nextPrompt: string) => { + const currentPrompt = promptRef.current; + if (nextPrompt === currentPrompt) { + scheduleComposerFocus(); + return; + } + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + [scheduleComposerFocus, setPrompt], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { @@ -3509,65 +3621,73 @@ 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 && ( + {/* Textarea area */} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && (
{composerImages.map((image) => (
)} - - )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
-
- ) : ( -
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : (
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - + {/* Provider/model picker */} + - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : null} - - - - - + ) : ( + <> + {selectedProvider === "codex" ? ( + <> + + + + ) : selectedProvider === "claudeAgent" ? ( + <> + + + + ) : 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 a new thread - - - -
+ +
+ + void onImplementPlanInNewThread()} + > + Implement in new thread + + +
+
+ ) + ) : ( + ) - ) : ( - - ) - ) : null} + ) : null} +
-
- )} + )} +
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a3ddbaa0f..7d210fa173 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -146,7 +146,7 @@ export const OpenAI: Icon = (props) => ( export const ClaudeAI: Icon = (props) => ( diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx new file mode 100644 index 0000000000..3c11fa5a05 --- /dev/null +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -0,0 +1,174 @@ +import "../../index.css"; + +import { ThreadId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; +import { useComposerDraftStore } from "../../composerDraftStore"; + +async function mountPicker(props?: { + model?: string; + prompt?: string; + effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null; + thinkingEnabled?: boolean | null; + fastModeEnabled?: boolean; +}) { + const threadId = ThreadId.makeUnsafe("thread-claude-traits"); + useComposerDraftStore.setState({ + draftsByThreadId: { + [threadId]: { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: "claudeAgent", + model: props?.model ?? "claude-opus-4-6", + modelOptions: { + claudeAgent: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + }, + runtimeMode: null, + interactionMode: null, + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + const host = document.createElement("div"); + document.body.append(host); + const onPromptChange = vi.fn(); + const screen = await render( + , + { container: host }, + ); + + return { + onPromptChange, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("ClaudeTraitsPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("shows fast mode controls for Opus", async () => { + const mounted = await mountPicker(); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hides fast mode controls for non-Opus models", async () => { + const mounted = await mountPicker({ model: "claude-sonnet-4-6" }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").not.toContain("Fast Mode"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows only the provided effort options", async () => { + const mounted = await mountPicker({ + model: "claude-sonnet-4-6", + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).not.toContain("Max"); + expect(text).toContain("Ultrathink"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows a thinking on/off dropdown for Haiku", async () => { + const mounted = await mountPicker({ + model: "claude-haiku-4-5", + thinkingEnabled: true, + }); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Thinking On"); + }); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking"); + expect(text).toContain("On (default)"); + expect(text).toContain("Off"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { + const mounted = await mountPicker({ + effort: "high", + model: "claude-opus-4-6", + prompt: "Ultrathink:\nInvestigate this", + fastModeEnabled: false, + }); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Ultrathink"); + expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); + }); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Effort"); + expect(text).toContain("Remove Ultrathink from the prompt to change effort."); + expect(text).not.toContain("Fallback Effort"); + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx new file mode 100644 index 0000000000..2baf197ab0 --- /dev/null +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -0,0 +1,257 @@ +import { + type ClaudeCodeEffort, + type ClaudeModelOptions, + type ProviderModelOptions, + type ThreadId, +} from "@t3tools/contracts"; +import { + applyClaudePromptEffortPrefix, + getDefaultReasoningEffort, + getReasoningEffortOptions, + normalizeClaudeModelOptions, + resolveReasoningEffortForProvider, + supportsClaudeFastMode, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, + isClaudeUltrathinkPrompt, +} from "@t3tools/shared/model"; +import { memo, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "../ui/menu"; +import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; + +const CLAUDE_EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + max: "Max", + ultrathink: "Ultrathink", +}; + +const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; + +function getSelectedClaudeTraits( + model: string | null | undefined, + prompt: string, + modelOptions: ClaudeModelOptions | null | undefined, +): { + effort: Exclude | null; + thinkingEnabled: boolean | null; + fastModeEnabled: boolean; + options: ReadonlyArray; + ultrathinkPromptControlled: boolean; + supportsFastMode: boolean; +} { + const options = getReasoningEffortOptions("claudeAgent", model); + const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude< + ClaudeCodeEffort, + "ultrathink" + >; + const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const effort = + resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort) + ? resolvedEffort + : options.includes(defaultReasoningEffort) + ? defaultReasoningEffort + : null; + const thinkingEnabled = supportsClaudeThinkingToggle(model) + ? (modelOptions?.thinking ?? true) + : null; + const supportsFastMode = supportsClaudeFastMode(model); + return { + effort, + thinkingEnabled, + fastModeEnabled: supportsFastMode && modelOptions?.fastMode === true, + options, + ultrathinkPromptControlled: + supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt), + supportsFastMode, + }; +} + +function ClaudeTraitsMenuContentImpl(props: { + threadId: ThreadId; + model: string | null | undefined; + onPromptChange: (prompt: string) => void; +}) { + const draft = useComposerThreadDraft(props.threadId); + const prompt = draft.prompt; + const modelOptions = draft.modelOptions?.claudeAgent; + const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); + const { + effort, + thinkingEnabled, + fastModeEnabled, + options, + ultrathinkPromptControlled, + supportsFastMode, + } = getSelectedClaudeTraits(props.model, prompt, modelOptions); + const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + + const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => { + const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } = + draft.modelOptions ?? {}; + const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions + ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions } + : Object.keys(otherProviderModelOptions).length > 0 + ? otherProviderModelOptions + : undefined; + setModelOptions(props.threadId, nextProviderModelOptions); + }; + + if (effort === null && thinkingEnabled === null) { + return null; + } + + return ( + <> + {effort ? ( + <> + +
Effort
+ {ultrathinkPromptControlled ? ( +
+ Remove Ultrathink from the prompt to change effort. +
+ ) : null} + { + if (ultrathinkPromptControlled) return; + if (!value) return; + const nextEffort = options.find((option) => option === value); + if (!nextEffort) return; + if (nextEffort === "ultrathink") { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + props.onPromptChange(nextPrompt); + return; + } + setClaudeModelOptions( + normalizeClaudeModelOptions(props.model, { + ...modelOptions, + effort: nextEffort, + }), + ); + }} + > + {options.map((option) => ( + + {CLAUDE_EFFORT_LABELS[option]} + {option === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + ) : thinkingEnabled !== null ? ( + +
Thinking
+ { + setClaudeModelOptions( + normalizeClaudeModelOptions(props.model, { + ...modelOptions, + thinking: value === "on", + }), + ); + }} + > + On (default) + Off + +
+ ) : null} + {supportsFastMode ? ( + <> + + +
Fast Mode
+ { + setClaudeModelOptions( + normalizeClaudeModelOptions(props.model, { + ...modelOptions, + fastMode: value === "on", + }), + ); + }} + > + off + on + +
+ + ) : null} + + ); +} + +export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl); + +export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: { + threadId: ThreadId; + model: string | null | undefined; + onPromptChange: (prompt: string) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const draft = useComposerThreadDraft(props.threadId); + const prompt = draft.prompt; + const modelOptions = draft.modelOptions?.claudeAgent; + const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = + getSelectedClaudeTraits(props.model, prompt, modelOptions); + const triggerLabel = [ + ultrathinkPromptControlled + ? "Ultrathink" + : effort + ? CLAUDE_EFFORT_LABELS[effort] + : thinkingEnabled === null + ? null + : `Thinking ${thinkingEnabled ? "On" : "Off"}`, + ...(supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ] + .filter(Boolean) + .join(" · "); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel} + + + + + + ); +}); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx new file mode 100644 index 0000000000..494913dc76 --- /dev/null +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -0,0 +1,167 @@ +import "../../index.css"; + +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { CodexTraitsPicker } from "./CodexTraitsPicker"; +import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore"; + +async function mountPicker(props: { + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; + fastModeEnabled: boolean; +}) { + const threadId = ThreadId.makeUnsafe("thread-codex-traits"); + useComposerDraftStore.setState({ + draftsByThreadId: { + [threadId]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: "codex", + model: null, + modelOptions: { + codex: { + ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), + ...(props.fastModeEnabled ? { fastMode: true } : {}), + }, + }, + runtimeMode: null, + interactionMode: null, + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: { + [ProjectId.makeUnsafe("project-codex-traits")]: threadId, + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { container: host }); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("CodexTraitsPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("shows fast mode controls", async () => { + const mounted = await mountPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows Fast in the trigger label when fast mode is active", async () => { + const mounted = await mountPicker({ + fastModeEnabled: true, + }); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("High · Fast"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows only the provided effort options", async () => { + const mounted = await mountPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).toContain("Extra High"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hydrates legacy codex persisted state into modelOptions through the picker", async () => { + const threadId = ThreadId.makeUnsafe("thread-codex-legacy"); + localStorage.setItem( + COMPOSER_DRAFT_STORAGE_KEY, + JSON.stringify({ + state: { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + provider: "codex", + model: "gpt-5.3-codex", + effort: "xhigh", + codexFastMode: true, + serviceTier: "fast", + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }, + version: 1, + }), + ); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { container: host }); + + try { + await useComposerDraftStore.persist.rehydrate(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Extra High · Fast"); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + fastMode: true, + }, + }); + }); + } finally { + await screen.unmount(); + host.remove(); + } + }); +}); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 6c72f497ba..914eca5540 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,7 +1,18 @@ -import { type CodexReasoningEffort } from "@t3tools/contracts"; -import { getDefaultReasoningEffort } from "@t3tools/shared/model"; +import { + type CodexModelOptions, + type CodexReasoningEffort, + type ProviderModelOptions, + type ThreadId, +} from "@t3tools/contracts"; +import { + getDefaultReasoningEffort, + getReasoningEffortOptions, + normalizeCodexModelOptions, + resolveReasoningEffortForProvider, +} from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; +import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; import { Button } from "../ui/button"; import { Menu, @@ -13,25 +24,99 @@ import { MenuTrigger, } from "../ui/menu"; -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { +const CODEX_REASONING_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; + +function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): { effort: CodexReasoningEffort; fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); +} { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + return { + effort: + resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + defaultReasoningEffort, + fastModeEnabled: modelOptions?.fastMode === true, + }; +} + +function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { + const draft = useComposerThreadDraft(props.threadId); + const modelOptions = draft.modelOptions?.codex; + const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); + const options = getReasoningEffortOptions("codex"); const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", + const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); + + const setCodexModelOptions = (nextCodexModelOptions: CodexModelOptions | undefined) => { + const { codex: _discardedCodex, ...otherProviderModelOptions } = draft.modelOptions ?? {}; + const nextProviderModelOptions: ProviderModelOptions | undefined = nextCodexModelOptions + ? { ...otherProviderModelOptions, codex: nextCodexModelOptions } + : Object.keys(otherProviderModelOptions).length > 0 + ? otherProviderModelOptions + : undefined; + setModelOptions(props.threadId, nextProviderModelOptions); }; - const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), - ] + + return ( + <> + +
Reasoning
+ { + if (!value) return; + const nextEffort = options.find((option) => option === value); + if (!nextEffort) return; + setCodexModelOptions( + normalizeCodexModelOptions({ + ...modelOptions, + reasoningEffort: nextEffort, + }), + ); + }} + > + {options.map((option) => ( + + {CODEX_REASONING_LABELS[option]} + {option === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + setCodexModelOptions( + normalizeCodexModelOptions({ + ...modelOptions, + fastMode: value === "on", + }), + ); + }} + > + off + on + +
+ + ); +} + +export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); + +export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex; + const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); + const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) .join(" · "); @@ -55,38 +140,7 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {