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/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/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d15b2efa2e..5fbe3016fd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1729,6 +1729,8 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const threadId = ThreadId.makeUnsafe("thread-restart"); const turnId = TurnId.makeUnsafe("turn-restart"); const messageId = MessageId.makeUnsafe("message-restart"); + const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source"); + const sourcePlanId = "plan-source"; const turnStartedAt = "2026-02-26T14:00:00.000Z"; const sessionSetAt = "2026-02-26T14:00:05.000Z"; @@ -1749,6 +1751,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta payload: { threadId, messageId, + sourceProposedPlan: { + threadId: sourcePlanThreadId, + planId: sourcePlanId, + }, runtimeMode: "approval-required", createdAt: turnStartedAt, }, @@ -1800,11 +1806,15 @@ it.effect("restores pending turn-start metadata across projection pipeline resta return yield* sql<{ readonly turnId: string; readonly userMessageId: string | null; + readonly sourceProposedPlanThreadId: string | null; + readonly sourceProposedPlanId: string | null; readonly startedAt: string; }>` SELECT turn_id AS "turnId", pending_message_id AS "userMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", started_at AS "startedAt" FROM projection_turns WHERE turn_id = ${turnId} @@ -1815,6 +1825,8 @@ it.effect("restores pending turn-start metadata across projection pipeline resta { turnId: "turn-restart", userMessageId: "message-restart", + sourceProposedPlanThreadId: "thread-plan-source", + sourceProposedPlanId: "plan-source", startedAt: turnStartedAt, }, ]); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ce633aecad..d46764cc8c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -808,6 +808,16 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { pendingMessageId: existingTurn.value.pendingMessageId ?? (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null), + sourceProposedPlanThreadId: + existingTurn.value.sourceProposedPlanThreadId ?? + (Option.isSome(pendingTurnStart) + ? pendingTurnStart.value.sourceProposedPlanThreadId + : null), + sourceProposedPlanId: + existingTurn.value.sourceProposedPlanId ?? + (Option.isSome(pendingTurnStart) + ? pendingTurnStart.value.sourceProposedPlanId + : null), startedAt: existingTurn.value.startedAt ?? (Option.isSome(pendingTurnStart) @@ -826,6 +836,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { pendingMessageId: Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null, + sourceProposedPlanThreadId: Option.isSome(pendingTurnStart) + ? pendingTurnStart.value.sourceProposedPlanThreadId + : null, + sourceProposedPlanId: Option.isSome(pendingTurnStart) + ? pendingTurnStart.value.sourceProposedPlanId + : null, assistantMessageId: null, state: "running", requestedAt: Option.isSome(pendingTurnStart) @@ -879,6 +895,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { turnId: event.payload.turnId, threadId: event.payload.threadId, pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, assistantMessageId: event.payload.messageId, state: event.payload.streaming ? "running" : "completed", requestedAt: event.payload.createdAt, @@ -914,6 +932,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { turnId: event.payload.turnId, threadId: event.payload.threadId, pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, assistantMessageId: null, state: "interrupted", requestedAt: event.payload.createdAt, @@ -958,6 +978,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { turnId: event.payload.turnId, threadId: event.payload.threadId, pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, assistantMessageId: event.payload.assistantMessageId, state: nextState, requestedAt: event.payload.completedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9bfe581de1..b5b73fd6e0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -178,6 +178,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { thread_id, turn_id, pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, assistant_message_id, state, requested_at, @@ -192,6 +194,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'turn-1', NULL, + 'thread-1', + 'plan-1', 'message-1', 'completed', '2026-02-24T00:00:08.000Z', @@ -262,6 +266,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { startedAt: "2026-02-24T00:00:08.000Z", completedAt: "2026-02-24T00:00:08.000Z", assistantMessageId: asMessageId("message-1"), + sourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-1"), + planId: "plan-1", + }, }, createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1b5134fbc4..849d2fa3b6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -4,8 +4,10 @@ import { MessageId, NonNegativeInt, OrchestrationCheckpointFile, + OrchestrationProposedPlanId, OrchestrationReadModel, ProjectScript, + ThreadId, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -74,6 +76,8 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ startedAt: Schema.NullOr(IsoDateTime), completedAt: Schema.NullOr(IsoDateTime), assistantMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; @@ -286,7 +290,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { requested_at AS "requestedAt", started_at AS "startedAt", completed_at AS "completedAt", - assistant_message_id AS "assistantMessageId" + assistant_message_id AS "assistantMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId" FROM projection_turns WHERE turn_id IS NOT NULL ORDER BY thread_id ASC, requested_at DESC, turn_id DESC @@ -501,6 +507,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { startedAt: row.startedAt, completedAt: row.completedAt, assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), }); } 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 770f25ee12..c1ba48108f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { OrchestrationReadModel, + ProviderKind, ProviderRuntimeEvent, ProviderSession, } from "@t3tools/contracts"; @@ -49,7 +50,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; @@ -428,6 +429,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(); @@ -1870,6 +1925,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.", }, }); @@ -1932,8 +1988,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 ed58664b8f..3df47941af 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -373,7 +373,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/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index 72c6889c5a..9b6c9c5771 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -47,6 +47,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { thread_id, turn_id, pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, assistant_message_id, state, requested_at, @@ -61,6 +63,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ${row.threadId}, ${row.turnId}, ${row.pendingMessageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, ${row.assistantMessageId}, ${row.state}, ${row.requestedAt}, @@ -74,6 +78,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ON CONFLICT (thread_id, turn_id) DO UPDATE SET pending_message_id = excluded.pending_message_id, + source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, + source_proposed_plan_id = excluded.source_proposed_plan_id, assistant_message_id = excluded.assistant_message_id, state = excluded.state, requested_at = excluded.requested_at, @@ -168,6 +174,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { thread_id AS "threadId", turn_id AS "turnId", pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", assistant_message_id AS "assistantMessageId", state, requested_at AS "requestedAt", @@ -199,6 +207,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { thread_id AS "threadId", turn_id AS "turnId", pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", assistant_message_id AS "assistantMessageId", state, requested_at AS "requestedAt", diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index b5888fdce5..95dab450bf 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -35,6 +35,8 @@ export const ProjectionTurn = Schema.Struct({ threadId: ThreadId, turnId: Schema.NullOr(TurnId), pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), assistantMessageId: Schema.NullOr(MessageId), state: ProjectionTurnState, requestedAt: IsoDateTime, @@ -51,6 +53,8 @@ export const ProjectionTurnById = Schema.Struct({ threadId: ThreadId, turnId: TurnId, pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), assistantMessageId: Schema.NullOr(MessageId), state: ProjectionTurnState, requestedAt: IsoDateTime, 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..3f1c851970 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -0,0 +1,2335 @@ +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("treats user-aborted Claude results as interrupted without a runtime error", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).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: "result", + subtype: "error_during_execution", + is_error: false, + errors: ["Error: Request was aborted."], + stop_reason: "tool_use", + session_id: "sdk-session-abort", + uuid: "result-abort", + } 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", + "turn.completed", + ], + ); + + 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, "interrupted"); + assert.equal(turnCompleted.payload.errorMessage, "Error: Request was aborted."); + assert.equal(turnCompleted.payload.stopReason, "tool_use"); + } + }).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("captures ExitPlanMode as a proposed plan and denies auto-exit", () => { + 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* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "ExitPlanMode", + { + plan: "# Ship it\n\n- one\n- two", + allowedPrompts: [{ tool: "Bash", prompt: "run tests" }], + }, + { + signal: new AbortController().signal, + toolUseID: "tool-exit-1", + }, + ); + + const proposedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(proposedEvent._tag, "Some"); + if (proposedEvent._tag !== "Some") { + return; + } + assert.equal(proposedEvent.value.type, "turn.proposed.completed"); + if (proposedEvent.value.type !== "turn.proposed.completed") { + return; + } + assert.equal(proposedEvent.value.payload.planMarkdown, "# Ship it\n\n- one\n- two"); + assert.deepEqual(proposedEvent.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-exit-1"), + }); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "deny"); + const deniedResult = permissionResult as PermissionResult & { + message?: string; + }; + assert.equal(deniedResult.message?.includes("captured your proposed plan"), true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("extracts proposed plans from assistant ExitPlanMode snapshots", () => { + 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* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const proposedEventFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.proposed.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-exit-plan", + uuid: "assistant-exit-plan", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-exit-plan", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-exit-2", + name: "ExitPlanMode", + input: { + plan: "# Final plan\n\n- capture it", + }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + const proposedEvent = yield* Fiber.join(proposedEventFiber); + assert.equal(proposedEvent._tag, "Some"); + if (proposedEvent._tag !== "Some") { + return; + } + assert.equal(proposedEvent.value.type, "turn.proposed.completed"); + if (proposedEvent.value.type !== "turn.proposed.completed") { + return; + } + assert.equal(proposedEvent.value.payload.planMarkdown, "# Final plan\n\n- capture it"); + assert.deepEqual(proposedEvent.value.providerRefs, { + providerItemId: ProviderItemId.makeUnsafe("tool-exit-2"), + }); + }).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..d69d34e1a0 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -0,0 +1,2720 @@ +/** + * 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; + readonly capturedProposedPlanKeys: Set; + 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; + 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 resultErrorsText(result: SDKResultMessage): string { + return "errors" in result && Array.isArray(result.errors) + ? result.errors.join(" ").toLowerCase() + : ""; +} + +function isInterruptedResult(result: SDKResultMessage): boolean { + const errors = resultErrorsText(result); + if (errors.includes("interrupt")) { + return true; + } + + return ( + result.subtype === "error_during_execution" && + result.is_error === false && + (errors.includes("request was aborted") || + errors.includes("interrupted by user") || + errors.includes("aborted")) + ); +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + +function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const cursor = resumeCursor as { + threadId?: unknown; + resume?: unknown; + sessionId?: unknown; + resumeSessionAt?: unknown; + turnCount?: unknown; + }; + + const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; + const threadId = + threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) + ? ThreadId.makeUnsafe(threadIdCandidate) + : undefined; + const resumeCandidate = + typeof cursor.resume === "string" + ? cursor.resume + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; + const resumeSessionAt = + typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; + const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + + return { + ...(threadId ? { threadId } : {}), + ...(resume ? { resume } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 + ? { turnCount: turnCountValue } + : {}), + }; +} + +function classifyToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("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 = resultErrorsText(result); + if (isInterruptedResult(result)) { + 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 extractExitPlanModePlan(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as { + plan?: unknown; + }; + return typeof record.plan === "string" && record.plan.trim().length > 0 + ? record.plan.trim() + : undefined; +} + +function exitPlanCaptureKey(input: { + readonly toolUseId?: string | undefined; + readonly planMarkdown: string; +}): string { + return input.toolUseId && input.toolUseId.length > 0 + ? `tool:${input.toolUseId}` + : `plan:${input.planMarkdown}`; +} + +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 emitProposedPlanCompleted = ( + context: ClaudeSessionContext, + input: { + readonly planMarkdown: string; + readonly toolUseId?: string | undefined; + readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; + readonly rawMethod: string; + readonly rawPayload: unknown; + }, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const planMarkdown = input.planMarkdown.trim(); + if (!turnState || planMarkdown.length === 0) { + return; + } + + const captureKey = exitPlanCaptureKey({ + toolUseId: input.toolUseId, + planMarkdown, + }); + if (turnState.capturedProposedPlanKeys.has(captureKey)) { + return; + } + turnState.capturedProposedPlanKeys.add(captureKey); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + planMarkdown, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: input.toolUseId, + }), + raw: { + source: input.rawSource, + method: input.rawMethod, + payload: input.rawPayload, + }, + }); + }); + + 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: [], + capturedProposedPlanKeys: new Set(), + 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: {}, + }, + }); + } + + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const toolUse = block as { + type?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; + }; + if (toolUse.type !== "tool_use" || toolUse.name !== "ExitPlanMode") { + continue; + } + const planMarkdown = extractExitPlanModePlan(toolUse.input); + if (!planMarkdown) { + continue; + } + yield* emitProposedPlanCompleted(context, { + planMarkdown, + toolUseId: typeof toolUse.id === "string" ? toolUse.id : undefined, + rawSource: "claude.sdk.message", + rawMethod: "claude/assistant", + rawPayload: message, + }); + } + } + + 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); + } + + if (toolName === "ExitPlanMode") { + const planMarkdown = extractExitPlanModePlan(toolInput); + if (planMarkdown) { + yield* emitProposedPlanCompleted(context, { + planMarkdown, + toolUseId: callbackOptions.toolUseID, + rawSource: "claude.sdk.permission", + rawMethod: "canUseTool/ExitPlanMode", + rawPayload: { + toolName, + input: toolInput, + }, + }); + } + + return { + behavior: "deny", + message: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: 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 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 } : {}), + ...(input.model ? { model: input.model } : {}), + ...(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: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); + + const session: ProviderSession = { + threadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + startedAt, + basePermissionMode: permissionMode, + 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) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(input.model), + 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: [], + capturedProposedPlanKeys: new Set(), + 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/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 15579809a4..605b281df3 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,8 @@ +import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { + AppSettingsSchema, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, @@ -20,6 +22,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 +55,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 +81,35 @@ 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); + }); +}); + +describe("AppSettingsSchema", () => { + it("fills decoding defaults for persisted settings that predate newer keys", () => { + const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); + + expect( + decode( + JSON.stringify({ + codexBinaryPath: "/usr/local/bin/codex", + confirmThreadDelete: false, + }), + ), + ).toMatchObject({ + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "", + defaultThreadEnvMode: "local", + confirmThreadDelete: false, + enableAssistantStreaming: false, + timestampFormat: DEFAULT_TIMESTAMP_FORMAT, + customCodexModels: [], + customClaudeModels: [], + }); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d060c2ef06..e4f4d8b1ca 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -3,37 +3,43 @@ import { Option, Schema } from "effect"; import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; +import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; -export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; + +export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); +export type TimestampFormat = typeof TimestampFormat.Type; 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({ - codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( - Schema.withConstructorDefault(() => Option.some("local")), - ), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), - enableAssistantStreaming: Schema.Boolean.pipe( - Schema.withConstructorDefault(() => Option.some(false)), - ), - timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( - Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), - ), - customCodexModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), +const withDefaults = + < + S extends Schema.Top & Schema.WithoutConstructorDefault, + D extends S["~type.make.in"] & S["Encoded"], + >( + fallback: () => D, + ) => + (schema: S) => + schema.pipe( + Schema.withConstructorDefault(() => Option.some(fallback())), + Schema.withDecodingDefault(() => fallback()), + ); + +export const AppSettingsSchema = Schema.Struct({ + codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), + confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), + enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), + customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -74,6 +80,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 +165,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/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 888c52cfd0..2215569c83 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,6 +1,8 @@ import type { GitBranch } from "@t3tools/contracts"; +import { Schema } from "effect"; -export type EnvMode = "local" | "worktree"; +export const EnvMode = Schema.Literals(["local", "worktree"]); +export type EnvMode = typeof EnvMode.Type; export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; 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 9ebb4ec9e8..eaead424fb 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"; @@ -53,6 +59,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, + findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -146,7 +153,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"; @@ -181,6 +189,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; @@ -255,8 +274,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); @@ -579,7 +596,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) { @@ -591,21 +615,53 @@ 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]); + if (selectedProvider === "claudeAgent") { + const claudeOptions = normalizeClaudeModelOptions( + selectedModel, + draftModelOptions?.claudeAgent, + ); + return claudeOptions ? { claudeAgent: claudeOptions } : undefined; + } + return undefined; + }, [draftModelOptions, selectedModel, selectedProvider]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; @@ -714,6 +770,16 @@ export default function ChatView({ threadId }: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + const sidebarProposedPlan = useMemo( + () => + findSidebarProposedPlan({ + threads, + latestTurn: activeLatestTurn, + latestTurnSettled, + threadId: activeThread?.id ?? null, + }), + [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], + ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], @@ -1554,7 +1620,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1563,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return !open; }); - }, [activePlan?.turnId, activeProposedPlan?.turnId]); + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2361,7 +2427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - await handleInteractionModeChange(standaloneSlashCommand); + handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -2414,6 +2480,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + effort: selectedPromptEffort, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -2436,7 +2507,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: messageTextForSend, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2587,7 +2658,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, @@ -2830,6 +2901,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"); @@ -2839,7 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2867,7 +2943,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -2920,6 +2996,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -2949,6 +3026,11 @@ 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 || @@ -2985,7 +3067,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -3043,6 +3125,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -3061,32 +3144,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) => { @@ -3519,65 +3604,71 @@ 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} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} - {!isComposerApprovalState && pendingUserInputs.length === 0 && ( - <> - {composerImages.length > 0 && ( + {!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 || sidebarProposedPlan || 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 a new thread + + +
+
+ ) + ) : ( + ) - ) : ( - - ) - ) : null} + ) : null} +
-
- )} + )} +
@@ -4029,14 +4139,14 @@ export default function ChatView({ threadId }: ChatViewProps) { {planSidebarOpen ? ( { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } 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..fd80e10473 --- /dev/null +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -0,0 +1,176 @@ +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"); + const draftsByThreadId = {} as ReturnType< + typeof useComposerDraftStore.getState + >["draftsByThreadId"]; + 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, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + 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..d717f91923 --- /dev/null +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -0,0 +1,169 @@ +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"); + const draftsByThreadId = {} as ReturnType< + typeof useComposerDraftStore.getState + >["draftsByThreadId"]; + 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, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + 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 a8a65250ed..641d39277e 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, + CodexReasoningEffort, + ProviderModelOptions, + 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,22 +24,101 @@ 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"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", + 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 { 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 effortLabel = reasoningLabelByOption[props.effort]; + + 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(" · "); return ( - {props.fastModeEnabled ? ( - - {effortLabel} - - Fast - - ) : ( - {effortLabel} - )} + {triggerLabel} - -
Reasoning
- { - if (!value) return; - const nextEffort = props.options.find((option) => option === value); - if (!nextEffort) return; - props.onEffortChange(nextEffort); - }} - > - {props.options.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+
); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx new file mode 100644 index 0000000000..83716d619a --- /dev/null +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -0,0 +1,186 @@ +import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker"; +import { CodexTraitsMenuContent } from "./CodexTraitsPicker"; +import { useComposerDraftStore } from "../../composerDraftStore"; + +async function mountMenu(props?: { + model?: string; + prompt?: string; + provider?: "codex" | "claudeAgent"; + modelOptions?: ProviderModelOptions | null; +}) { + const threadId = ThreadId.makeUnsafe("thread-compact-menu"); + const provider = props?.provider ?? "claudeAgent"; + const draftsByThreadId = {} as ReturnType< + typeof useComposerDraftStore.getState + >["draftsByThreadId"]; + draftsByThreadId[threadId] = { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider, + model: props?.model ?? "claude-opus-4-6", + modelOptions: props?.modelOptions ?? null, + runtimeMode: null, + interactionMode: null, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + const host = document.createElement("div"); + document.body.append(host); + const onPromptChange = vi.fn(); + const screen = await render( + + ) : ( + + ) + } + onToggleInteractionMode={vi.fn()} + onTogglePlanSidebar={vi.fn()} + onToggleRuntimeMode={vi.fn()} + />, + { container: host }, + ); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("CompactComposerControlsMenu", () => { + afterEach(() => { + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("shows fast mode controls for Opus", async () => { + const mounted = await mountMenu(); + + try { + await page.getByLabelText("More composer controls").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 Claude models", async () => { + const mounted = await mountMenu({ model: "claude-sonnet-4-6" }); + + try { + await page.getByLabelText("More composer controls").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 mountMenu({ + model: "claude-sonnet-4-6", + }); + + try { + await page.getByLabelText("More composer controls").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 Claude thinking on/off section for Haiku", async () => { + const mounted = await mountMenu({ + model: "claude-haiku-4-5", + modelOptions: { + claudeAgent: { + thinking: true, + }, + }, + }); + + try { + await page.getByLabelText("More composer controls").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 messaging with disabled effort controls", async () => { + const mounted = await mountMenu({ + model: "claude-opus-4-6", + prompt: "Ultrathink:\nInvestigate this", + modelOptions: { + claudeAgent: { + effort: "high", + }, + }, + }); + + try { + await page.getByLabelText("More composer controls").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/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01e..db38ed8c1e 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,16 +1,9 @@ -import { - type CodexReasoningEffort, - type ProviderKind, - RuntimeMode, - ProviderInteractionMode, -} from "@t3tools/contracts"; -import { getDefaultReasoningEffort } from "@t3tools/shared/model"; -import { memo } from "react"; +import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; +import { memo, type ReactNode } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuGroup, MenuItem, MenuPopup, MenuRadioGroup, @@ -24,24 +17,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls interactionMode: ProviderInteractionMode; planSidebarOpen: boolean; runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; - selectedProvider: ProviderKind; - selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; - onCodexFastModeChange: (enabled: boolean) => void; + traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onToggleRuntimeMode: () => void; }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - return ( - {props.selectedProvider === "codex" && props.selectedEffort != null ? ( + {props.traitsMenuContent ? ( <> - -
Reasoning
- { - if (!value) return; - const nextEffort = props.reasoningOptions.find((option) => option === value); - if (!nextEffort) return; - props.onEffortSelect(nextEffort); - }} - > - {props.reasoningOptions.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onCodexFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.traitsMenuContent} ) : null} - -
Mode
- { - if (!value || value === props.interactionMode) return; - props.onToggleInteractionMode(); - }} - > - Chat - Plan - -
+
Mode
+ { + if (!value || value === props.interactionMode) return; + props.onToggleInteractionMode(); + }} + > + Chat + Plan + - -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); - }} - > - Supervised - Full access - -
+
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onToggleRuntimeMode(); + }} + > + Supervised + Full access + {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 12c7f60544..9da45ba3ff 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -12,18 +12,23 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ return null; } + const providerLabel = + status.provider === "codex" + ? "Codex" + : status.provider === "claudeAgent" + ? "Claude" + : status.provider; const defaultMessage = status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; + ? `${providerLabel} provider is unavailable.` + : `${providerLabel} provider has limited availability.`; + const title = providerLabel === "Codex" ? "Codex provider status" : `${providerLabel} status`; return (
- - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} - + {title} {status.message ?? defaultMessage} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx new file mode 100644 index 0000000000..11a3289f45 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -0,0 +1,94 @@ +import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ProviderModelPicker } from "./ProviderModelPicker"; + +const MODEL_OPTIONS_BY_PROVIDER = { + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], + codex: [ + { slug: "gpt-5-codex", name: "GPT-5 Codex" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], +} as const satisfies Record>; + +async function mountPicker(props: { + provider: ProviderKind; + model: ModelSlug; + lockedProvider: ProviderKind | null; +}) { + const host = document.createElement("div"); + document.body.append(host); + const onProviderModelChange = vi.fn(); + const screen = await render( + , + { container: host }, + ); + + return { + onProviderModelChange, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("ProviderModelPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("shows provider submenus when provider switching is allowed", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Codex"); + expect(text).toContain("Claude"); + expect(text).not.toContain("Claude Sonnet 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows models directly when the provider is locked mid-thread", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Sonnet 4.6"); + expect(text).toContain("Claude Haiku 4.5"); + expect(text).not.toContain("Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 0db4cdf289..e61ac6da5b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -25,7 +25,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } function resolveModelForProviderPicker( @@ -63,7 +63,7 @@ function resolveModelForProviderPicker( const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, - claudeCode: ClaudeAI, + claudeAgent: ClaudeAI, cursor: CursorIcon, }; @@ -74,20 +74,41 @@ const COMING_SOON_PROVIDER_OPTIONS = [ { id: "gemini", label: "Gemini", icon: Gemini }, ] as const; +function providerIconClassName( + provider: ProviderKind | ProviderPickerKind, + fallbackClassName: string, +): string { + return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; +} + export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; + ultrathinkActive?: boolean; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; + const activeProvider = props.lockedProvider ?? props.provider; + const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; + const handleModelChange = (provider: ProviderKind, value: string) => { + if (props.disabled) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker( + provider, + value, + props.modelOptionsByProvider[provider], + ); + if (!resolvedModel) return; + props.onProviderModelChange(provider, resolvedModel); + setIsMenuOpen(false); + }; return ( - ); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 7362eda089..773f16ceab 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,16 +1,19 @@ +import * as Schema from "effect/Schema"; import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + COMPOSER_DRAFT_STORAGE_KEY, type ComposerImageAttachment, - createDebouncedStorage, useComposerDraftStore, } from "./composerDraftStore"; +import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; +import { createDebouncedStorage } from "./lib/storage"; function makeImage(input: { id: string; @@ -183,6 +186,63 @@ describe("composerDraftStore clearComposerContent", () => { }); }); +describe("composerDraftStore syncPersistedAttachments", () => { + const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); + + beforeEach(() => { + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + afterEach(() => { + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + }); + + it("treats malformed persisted draft storage as empty", async () => { + const image = makeImage({ + id: "img-persisted", + previewUrl: "blob:persisted", + }); + useComposerDraftStore.getState().addImage(threadId, image); + setLocalStorageItem( + COMPOSER_DRAFT_STORAGE_KEY, + { + version: 2, + state: { + draftsByThreadId: { + [threadId]: { + attachments: "not-an-array", + }, + }, + }, + }, + Schema.Unknown, + ); + + useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.previewUrl, + }, + ]); + await Promise.resolve(); + + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, + ).toEqual([]); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, + ).toEqual([image.id]); + }); +}); + describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); @@ -323,6 +383,37 @@ describe("composerDraftStore terminal contexts", () => { }, ]); }); + + it("sanitizes malformed persisted drafts during merge", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const mergedState = persistApi.getOptions().merge( + { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: "not-an-array", + terminalContexts: "not-an-array", + provider: "bogus-provider", + modelOptions: "not-an-object", + }, + }, + draftThreadsByThreadId: "not-an-object", + projectDraftThreadIdByProjectId: "not-an-object", + }, + useComposerDraftStore.getInitialState(), + ); + + expect(mergedState.draftsByThreadId[threadId]).toBeUndefined(); + expect(mergedState.draftThreadsByThreadId).toEqual({}); + expect(mergedState.projectDraftThreadIdByProjectId).toEqual({}); + }); }); describe("composerDraftStore project draft thread mapping", () => { @@ -504,8 +595,8 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); -describe("composerDraftStore codex fast mode", () => { - const threadId = ThreadId.makeUnsafe("thread-service-tier"); +describe("composerDraftStore modelOptions", () => { + const threadId = ThreadId.makeUnsafe("thread-model-options"); beforeEach(() => { useComposerDraftStore.setState({ @@ -515,17 +606,39 @@ describe("composerDraftStore codex fast mode", () => { }); }); - it("stores codex fast mode in the draft", () => { + it("stores provider-scoped model options in the draft", () => { const store = useComposerDraftStore.getState(); - store.setCodexFastMode(threadId, true); + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "xhigh", + fastMode: true, + }, + claudeAgent: { + thinking: false, + }, + }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + fastMode: true, + }, + claudeAgent: { + thinking: false, + }, + }); }); - it("clears codex fast mode when reset to the default", () => { + it("drops default-only model options from the draft", () => { const store = useComposerDraftStore.getState(); - store.setCodexFastMode(threadId, true); - store.setCodexFastMode(threadId, false); + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "high", + }, + claudeAgent: { + thinking: true, + }, + }); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 364dc67a41..7733bfd62f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,60 +1,40 @@ import { + CODEX_REASONING_EFFORT_OPTIONS, + type ClaudeCodeEffort, + type CodexReasoningEffort, DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, - REASONING_EFFORT_OPTIONS_BY_PROVIDER, + ProviderInteractionMode, + ProviderKind, + ProviderModelOptions, + RuntimeMode, ThreadId, - type CodexReasoningEffort, - type ProviderKind, - type ProviderInteractionMode, - type RuntimeMode, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { DeepMutable } from "effect/Types"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; -import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -export type DraftThreadEnvMode = "local" | "worktree"; +const COMPOSER_DRAFT_STORAGE_VERSION = 2; +const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); +export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const COMPOSER_PERSIST_DEBOUNCE_MS = 300; -interface DebouncedStorage extends StateStorage { - flush: () => void; -} - -export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage { - const debouncedSetItem = new Debouncer( - (name: string, value: string) => { - baseStorage.setItem(name, value); - }, - { wait: COMPOSER_PERSIST_DEBOUNCE_MS }, - ); - - return { - getItem: (name) => baseStorage.getItem(name), - setItem: (name, value) => { - debouncedSetItem.maybeExecute(name, value); - }, - removeItem: (name) => { - debouncedSetItem.cancel(); - baseStorage.removeItem(name); - }, - flush: () => { - debouncedSetItem.flush(); - }, - }; -} - -const composerDebouncedStorage: DebouncedStorage = - typeof localStorage !== "undefined" - ? createDebouncedStorage(localStorage) - : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; +const composerDebouncedStorage = createDebouncedStorage( + typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), + COMPOSER_PERSIST_DEBOUNCE_MS, +); // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined") { @@ -63,57 +43,74 @@ if (typeof window !== "undefined") { }); } -export interface PersistedComposerImageAttachment { - id: string; - name: string; - mimeType: string; - sizeBytes: number; - dataUrl: string; -} +export const PersistedComposerImageAttachment = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); +export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit { previewUrl: string; file: File; } -interface PersistedTerminalContextDraft { - id: string; - threadId: ThreadId; - createdAt: string; - terminalId: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; -} +const PersistedTerminalContextDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + createdAt: Schema.String, + terminalId: Schema.String, + terminalLabel: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, +}); +type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; -interface PersistedComposerThreadDraftState { - prompt: string; - attachments: PersistedComposerImageAttachment[]; - terminalContexts?: PersistedTerminalContextDraft[]; - provider?: ProviderKind | null; - model?: string | null; - runtimeMode?: RuntimeMode | null; - interactionMode?: ProviderInteractionMode | null; - effort?: CodexReasoningEffort | null; - codexFastMode?: boolean | null; - serviceTier?: string | null; -} +const PersistedComposerThreadDraftState = Schema.Struct({ + prompt: Schema.String, + attachments: Schema.Array(PersistedComposerImageAttachment), + terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), + provider: Schema.optionalKey(ProviderKind), + model: Schema.optionalKey(Schema.String), + modelOptions: Schema.optionalKey(ProviderModelOptions), + runtimeMode: Schema.optionalKey(RuntimeMode), + interactionMode: Schema.optionalKey(ProviderInteractionMode), +}); +type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; -interface PersistedDraftThreadState { - projectId: ProjectId; - createdAt: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - branch: string | null; - worktreePath: string | null; - envMode: DraftThreadEnvMode; -} +const LegacyCodexFields = Schema.Struct({ + effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + codexFastMode: Schema.optionalKey(Schema.Boolean), + serviceTier: Schema.optionalKey(Schema.String), +}); +type LegacyCodexFields = typeof LegacyCodexFields.Type; -interface PersistedComposerDraftStoreState { - draftsByThreadId: Record; - draftThreadsByThreadId: Record; - projectDraftThreadIdByProjectId: Record; -} +type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields; + +const PersistedDraftThreadState = Schema.Struct({ + projectId: ProjectId, + createdAt: Schema.String, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + envMode: DraftThreadEnvModeSchema, +}); +type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; + +const PersistedComposerDraftStoreState = Schema.Struct({ + draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), + draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), + projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), +}); +type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; + +const PersistedComposerDraftStoreStorage = Schema.Struct({ + version: Schema.Number, + state: PersistedComposerDraftStoreState, +}); interface ComposerThreadDraftState { prompt: string; @@ -123,10 +120,9 @@ interface ComposerThreadDraftState { terminalContexts: TerminalContextDraft[]; provider: ProviderKind | null; model: string | null; + modelOptions: ProviderModelOptions | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; - effort: CodexReasoningEffort | null; - codexFastMode: boolean; } export interface DraftThreadState { @@ -180,13 +176,15 @@ interface ComposerDraftStoreState { setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; setModel: (threadId: ThreadId, model: string | null | undefined) => void; + setModelOptions: ( + threadId: ThreadId, + modelOptions: ProviderModelOptions | null | undefined, + ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, interactionMode: ProviderInteractionMode | null | undefined, ) => void; - setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void; - setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; @@ -222,8 +220,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); -Object.freeze(EMPTY_TERMINAL_CONTEXTS); -const EMPTY_THREAD_DRAFT = Object.freeze({ +const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, @@ -231,15 +228,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ terminalContexts: EMPTY_TERMINAL_CONTEXTS, provider: null, model: null, + modelOptions: null, runtimeMode: null, interactionMode: null, - effort: null, - codexFastMode: false, -}) as ComposerThreadDraftState; - -const REASONING_EFFORT_VALUES = new Set( - REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, -); +}); function createEmptyThreadDraft(): ComposerThreadDraftState { return { @@ -250,10 +242,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { terminalContexts: [], provider: null, model: null, + modelOptions: null, runtimeMode: null, interactionMode: null, - effort: null, - codexFastMode: false, }; } @@ -322,15 +313,88 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.terminalContexts.length === 0 && draft.provider === null && draft.model === null && + draft.modelOptions === null && draft.runtimeMode === null && - draft.interactionMode === null && - draft.effort === null && - draft.codexFastMode === false + draft.interactionMode === null ); } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "claudeAgent" ? value : null; +} + +function normalizeProviderModelOptions( + value: unknown, + provider?: ProviderKind | null, + legacy?: LegacyCodexFields, +): ProviderModelOptions | null { + const candidate = value && typeof value === "object" ? (value as Record) : null; + const codexCandidate = + candidate?.codex && typeof candidate.codex === "object" + ? (candidate.codex as Record) + : null; + const claudeCandidate = + candidate?.claudeAgent && typeof candidate.claudeAgent === "object" + ? (candidate.claudeAgent as Record) + : null; + + const codexReasoningEffort: CodexReasoningEffort | undefined = + codexCandidate?.reasoningEffort === "low" || + codexCandidate?.reasoningEffort === "medium" || + codexCandidate?.reasoningEffort === "high" || + codexCandidate?.reasoningEffort === "xhigh" + ? codexCandidate.reasoningEffort + : provider === "codex" && + (legacy?.effort === "low" || + legacy?.effort === "medium" || + legacy?.effort === "high" || + legacy?.effort === "xhigh") + ? legacy.effort + : undefined; + const codexFastMode = + codexCandidate?.fastMode === true || + (provider === "codex" && legacy?.codexFastMode === true) || + (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast"); + const codex = + codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex + ? { + reasoningEffort: codexReasoningEffort, + ...(codexFastMode ? { fastMode: true } : {}), + } + : codexFastMode + ? { fastMode: true } + : undefined; + + const claudeThinking = claudeCandidate?.thinking === false ? false : undefined; + const claudeEffort: ClaudeCodeEffort | undefined = + claudeCandidate?.effort === "low" || + claudeCandidate?.effort === "medium" || + claudeCandidate?.effort === "high" || + claudeCandidate?.effort === "max" || + claudeCandidate?.effort === "ultrathink" + ? claudeCandidate.effort + : undefined; + const claudeFastMode = claudeCandidate?.fastMode === true; + const claude = + claudeThinking === false || + (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) || + claudeFastMode + ? { + ...(claudeThinking === false ? { thinking: false } : {}), + ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent + ? { effort: claudeEffort } + : {}), + ...(claudeFastMode ? { fastMode: true } : {}), + } + : undefined; + + if (!codex && !claude) { + return null; + } + return { + ...(codex ? { codex } : {}), + ...(claude ? { claudeAgent: claude } : {}), + }; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -429,15 +493,14 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } -function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { - if (!value || typeof value !== "object") { - return EMPTY_PERSISTED_DRAFT_STORE_STATE; - } - const candidate = value as Record; - const rawDraftMap = candidate.draftsByThreadId; - const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; - const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; - const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; +function normalizePersistedDraftThreads( + rawDraftThreadsByThreadId: unknown, + rawProjectDraftThreadIdByProjectId: unknown, +): Pick< + PersistedComposerDraftStoreState, + "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" +> { + const draftThreadsByThreadId: Record = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { for (const [threadId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record, @@ -479,8 +542,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer }; } } - const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = - {}; + + const projectDraftThreadIdByProjectId: Record = {}; if ( rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object" @@ -514,10 +577,23 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } } } + + return { draftThreadsByThreadId, projectDraftThreadIdByProjectId }; +} + +function normalizePersistedDraftsByThreadId( + rawDraftMap: unknown, + resolveModelOptions: ( + draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState, + provider: ProviderKind | null, + ) => ProviderModelOptions | null, +): PersistedComposerDraftStoreState["draftsByThreadId"] { if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return {}; } - const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; + + const nextDraftsByThreadId: DeepMutable = + {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { if (typeof threadId !== "string" || threadId.length === 0) { continue; @@ -525,7 +601,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer if (!draftValue || typeof draftValue !== "object") { continue; } - const draftCandidate = draftValue as Record; + const draftCandidate = draftValue as + | PersistedComposerThreadDraftState + | LegacyPersistedCodexThreadDraftState; const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; const attachments = Array.isArray(draftCandidate.attachments) ? draftCandidate.attachments.flatMap((entry) => { @@ -553,15 +631,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; - const effortCandidate = - typeof draftCandidate.effort === "string" ? draftCandidate.effort : null; - const effort = - effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort) - ? (effortCandidate as CodexReasoningEffort) - : null; - const codexFastMode = - draftCandidate.codexFastMode === true || - (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + const modelOptions = resolveModelOptions(draftCandidate, provider); const prompt = ensureInlineTerminalContextPlaceholders( promptCandidate, terminalContexts.length, @@ -572,10 +642,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer terminalContexts.length === 0 && !provider && !model && + modelOptions === null && !runtimeMode && - !interactionMode && - !effort && - !codexFastMode + !interactionMode ) { continue; } @@ -585,32 +654,120 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(provider ? { provider } : {}), ...(model ? { model } : {}), + ...(modelOptions ? { modelOptions } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), - ...(effort ? { effort } : {}), - ...(codexFastMode ? { codexFastMode } : {}), }; } + + return nextDraftsByThreadId; +} + +function migratePersistedComposerDraftStoreState( + persistedState: unknown, + persistedVersion: number, +): PersistedComposerDraftStoreState { + if (!persistedState || typeof persistedState !== "object") { + return EMPTY_PERSISTED_DRAFT_STORE_STATE; + } + const candidate = persistedState as Record; + const rawDraftMap = candidate.draftsByThreadId; + const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; + const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = + normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); + const draftsByThreadId = normalizePersistedDraftsByThreadId( + rawDraftMap, + (draftCandidate, provider) => + persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION + ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider) + : normalizeProviderModelOptions( + draftCandidate.modelOptions, + provider, + draftCandidate as LegacyPersistedCodexThreadDraftState, + ), + ); return { - draftsByThreadId: nextDraftsByThreadId, + draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, }; } -function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { - if (!raw) { - return EMPTY_PERSISTED_DRAFT_STORE_STATE; - } - try { - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object" && "state" in parsed) { - return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); +function partializeComposerDraftStoreState( + state: ComposerDraftStoreState, +): PersistedComposerDraftStoreState { + const persistedDraftsByThreadId: DeepMutable< + PersistedComposerDraftStoreState["draftsByThreadId"] + > = {}; + for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { + if (typeof threadId !== "string" || threadId.length === 0) { + continue; } - return normalizePersistedComposerDraftState(parsed); - } catch { + if ( + draft.prompt.length === 0 && + draft.persistedAttachments.length === 0 && + draft.terminalContexts.length === 0 && + draft.provider === null && + draft.model === null && + draft.modelOptions === null && + draft.runtimeMode === null && + draft.interactionMode === null + ) { + continue; + } + const persistedDraft: DeepMutable = { + prompt: draft.prompt, + attachments: draft.persistedAttachments, + ...(draft.terminalContexts.length > 0 + ? { + terminalContexts: draft.terminalContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + createdAt: context.createdAt, + terminalId: context.terminalId, + terminalLabel: context.terminalLabel, + lineStart: context.lineStart, + lineEnd: context.lineEnd, + })), + } + : {}), + ...(draft.model ? { model: draft.model } : {}), + ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), + ...(draft.provider ? { provider: draft.provider } : {}), + ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), + ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), + }; + persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; + } + return { + draftsByThreadId: persistedDraftsByThreadId, + draftThreadsByThreadId: state.draftThreadsByThreadId, + projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + }; +} + +function normalizeCurrentPersistedComposerDraftStoreState( + persistedState: unknown, +): PersistedComposerDraftStoreState { + if (!persistedState || typeof persistedState !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } + const normalizedPersistedState = persistedState as Record; + const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = + normalizePersistedDraftThreads( + normalizedPersistedState.draftThreadsByThreadId, + normalizedPersistedState.projectDraftThreadIdByProjectId, + ); + return { + draftsByThreadId: normalizePersistedDraftsByThreadId( + normalizedPersistedState.draftsByThreadId, + (draftCandidate, provider) => + normalizeProviderModelOptions(draftCandidate.modelOptions, provider), + ), + draftThreadsByThreadId, + projectDraftThreadIdByProjectId, + }; } function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { @@ -618,9 +775,14 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { return []; } try { - const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); - const persisted = parsePersistedDraftStateRaw(raw); - return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map( + const persisted = getLocalStorageItem( + COMPOSER_DRAFT_STORAGE_KEY, + PersistedComposerDraftStoreStorage, + ); + if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { + return []; + } + return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map( (attachment) => attachment.id, ); } catch { @@ -661,7 +823,7 @@ function hydreatePersistedComposerImageAttachment( } function hydrateImagesFromPersisted( - attachments: PersistedComposerImageAttachment[], + attachments: ReadonlyArray, ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { const file = hydreatePersistedComposerImageAttachment(attachment); @@ -688,7 +850,7 @@ function toHydratedThreadDraft( prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), nonPersistedImageIds: [], - persistedAttachments: persistedDraft.attachments, + persistedAttachments: [...persistedDraft.attachments], terminalContexts: persistedDraft.terminalContexts?.map((context) => ({ ...context, @@ -696,10 +858,9 @@ function toHydratedThreadDraft( })) ?? [], provider: persistedDraft.provider ?? null, model: persistedDraft.model ?? null, + modelOptions: persistedDraft.modelOptions ?? null, runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, - effort: persistedDraft.effort ?? null, - codexFastMode: persistedDraft.codexFastMode === true, }; } @@ -1018,9 +1179,9 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const normalizedModel = normalizeModelSlug(model) ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; + const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; if (!existing && normalizedModel === null) { return state; } @@ -1041,24 +1202,24 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setRuntimeMode: (threadId, runtimeMode) => { + setModelOptions: (threadId, modelOptions) => { if (threadId.length === 0) { return; } - const nextRuntimeMode = - runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextRuntimeMode === null) { + const provider = existing?.provider ?? null; + const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider); + if (!existing && nextModelOptions === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (base.runtimeMode === nextRuntimeMode) { + if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - runtimeMode: nextRuntimeMode, + modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1069,56 +1230,24 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setInteractionMode: (threadId, interactionMode) => { - if (threadId.length === 0) { - return; - } - const nextInteractionMode = - interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && nextInteractionMode === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.interactionMode === nextInteractionMode) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - interactionMode: nextInteractionMode, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setEffort: (threadId, effort) => { + setRuntimeMode: (threadId, runtimeMode) => { if (threadId.length === 0) { return; } - const nextEffort = - effort && - REASONING_EFFORT_VALUES.has(effort) && - effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex - ? effort - : null; + const nextRuntimeMode = + runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextEffort === null) { + if (!existing && nextRuntimeMode === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (base.effort === nextEffort) { + if (base.runtimeMode === nextRuntimeMode) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - effort: nextEffort, + runtimeMode: nextRuntimeMode, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1129,23 +1258,24 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setCodexFastMode: (threadId, enabled) => { + setInteractionMode: (threadId, interactionMode) => { if (threadId.length === 0) { return; } - const nextCodexFastMode = enabled === true; + const nextInteractionMode = + interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextCodexFastMode === false) { + if (!existing && nextInteractionMode === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (base.codexFastMode === nextCodexFastMode) { + if (base.interactionMode === nextInteractionMode) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - codexFastMode: nextCodexFastMode, + interactionMode: nextInteractionMode, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1496,70 +1626,13 @@ export const useComposerDraftStore = create()( }), { name: COMPOSER_DRAFT_STORAGE_KEY, - version: 1, + version: COMPOSER_DRAFT_STORAGE_VERSION, storage: createJSONStorage(() => composerDebouncedStorage), - partialize: (state) => { - const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; - for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { - if (typeof threadId !== "string" || threadId.length === 0) { - continue; - } - if ( - draft.prompt.length === 0 && - draft.persistedAttachments.length === 0 && - draft.terminalContexts.length === 0 && - draft.provider === null && - draft.model === null && - draft.runtimeMode === null && - draft.interactionMode === null && - draft.effort === null && - draft.codexFastMode === false - ) { - continue; - } - const persistedDraft: PersistedComposerThreadDraftState = { - prompt: draft.prompt, - attachments: draft.persistedAttachments, - }; - if (draft.terminalContexts.length > 0) { - persistedDraft.terminalContexts = draft.terminalContexts.map((context) => ({ - id: context.id, - threadId: context.threadId, - createdAt: context.createdAt, - terminalId: context.terminalId, - terminalLabel: context.terminalLabel, - lineStart: context.lineStart, - lineEnd: context.lineEnd, - })); - } - if (draft.model) { - persistedDraft.model = draft.model; - } - if (draft.provider) { - persistedDraft.provider = draft.provider; - } - if (draft.runtimeMode) { - persistedDraft.runtimeMode = draft.runtimeMode; - } - if (draft.interactionMode) { - persistedDraft.interactionMode = draft.interactionMode; - } - if (draft.effort) { - persistedDraft.effort = draft.effort; - } - if (draft.codexFastMode) { - persistedDraft.codexFastMode = true; - } - persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; - } - return { - draftsByThreadId: persistedDraftsByThreadId, - draftThreadsByThreadId: state.draftThreadsByThreadId, - projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, - }; - }, + migrate: migratePersistedComposerDraftStoreState, + partialize: partializeComposerDraftStoreState, merge: (persistedState, currentState) => { - const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); + const normalizedPersisted = + normalizeCurrentPersistedComposerDraftStoreState(persistedState); const draftsByThreadId = Object.fromEntries( Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ threadId, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2281791619..ea76f24fac 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -404,3 +404,66 @@ label:has(> select#reasoning-effort) select { overflow: clip; background: color-mix(in srgb, var(--card) 92%, var(--background)); } + +@keyframes ultrathink-rainbow { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 200% 50%; + } +} + +@keyframes ultrathink-chroma-shift { + 0% { + filter: hue-rotate(0deg) saturate(1.2); + } + 100% { + filter: hue-rotate(360deg) saturate(1.2); + } +} + +.ultrathink-frame { + --ultrathink-spectrum: linear-gradient( + 120deg, + #ff6b6b 0%, + #f59e0b 18%, + #22c55e 36%, + #14b8a6 54%, + #3b82f6 72%, + #ec4899 90%, + #ff6b6b 100% + ); + background-image: var(--ultrathink-spectrum); + background-size: 220% 220%; + animation: ultrathink-rainbow 10s linear infinite; +} + +.ultrathink-chroma { + animation: ultrathink-chroma-shift 10s linear infinite; +} + +.ultrathink-pill { + background: + linear-gradient(var(--card), var(--card)) padding-box, + var(--ultrathink-spectrum) border-box; + background-size: + 100% 100%, + 220% 220%; + background-position: + 0 0, + 0% 50%; + animation: ultrathink-rainbow 10s linear infinite; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent); +} + +.ultrathink-word { + display: inline-block; + color: transparent; + background-image: var(--ultrathink-spectrum); + background-size: 220% 220%; + background-position: 0% 50%; + background-clip: text; + -webkit-background-clip: text; + animation: ultrathink-rainbow 10s linear infinite; +} diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts new file mode 100644 index 0000000000..eeb3a03a82 --- /dev/null +++ b/apps/web/src/lib/storage.ts @@ -0,0 +1,50 @@ +import { Debouncer } from "@tanstack/react-pacer"; + +export interface StateStorage { + getItem: (name: string) => string | null | Promise; + setItem: (name: string, value: string) => R; + removeItem: (name: string) => R; +} + +export interface DebouncedStorage extends StateStorage { + flush: () => void; +} + +export function createMemoryStorage(): StateStorage { + const store = new Map(); + return { + getItem: (name) => store.get(name) ?? null, + setItem: (name, value) => { + store.set(name, value); + }, + removeItem: (name) => { + store.delete(name); + }, + }; +} + +export function createDebouncedStorage( + baseStorage: StateStorage, + debounceMs: number = 300, +): DebouncedStorage { + const debouncedSetItem = new Debouncer( + (name: string, value: string) => { + baseStorage.setItem(name, value); + }, + { wait: debounceMs }, + ); + + return { + getItem: (name) => baseStorage.getItem(name), + setItem: (name, value) => { + debouncedSetItem.maybeExecute(name, value); + }, + removeItem: (name) => { + debouncedSetItem.cancel(); + baseStorage.removeItem(name); + }, + flush: () => { + debouncedSetItem.flush(); + }, + }; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e79592c99b..f3dee29096 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "claudeAgent", + title: "Claude", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, ] as const; const TIMESTAMP_FORMAT_LABELS = { @@ -67,6 +74,8 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeAgent": + return settings.customClaudeModels; case "codex": default: return settings.customCodexModels; @@ -78,6 +87,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeAgent": + return defaults.customClaudeModels; case "codex": default: return defaults.customCodexModels; @@ -86,6 +97,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "claudeAgent": + return { customClaudeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -102,6 +115,7 @@ function SettingsRouteView() { Record >({ codex: "", + claudeAgent: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 36c5b86191..5348bbb965 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -16,6 +16,7 @@ import { deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, + findSidebarProposedPlan, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, @@ -372,6 +373,103 @@ describe("hasActionableProposedPlan", () => { }); }); +describe("findSidebarProposedPlan", () => { + it("prefers the running turn source proposed plan when available on the same thread", () => { + expect( + findSidebarProposedPlan({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.makeUnsafe("turn-plan"), + planMarkdown: "# Source plan", + implementedAt: "2026-02-23T00:00:03.000Z", + implementationThreadId: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }, + ], + }, + { + id: ThreadId.makeUnsafe("thread-2"), + proposedPlans: [ + { + id: "plan-2", + turnId: TurnId.makeUnsafe("turn-other"), + planMarkdown: "# Latest elsewhere", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-23T00:00:04.000Z", + updatedAt: "2026-02-23T00:00:05.000Z", + }, + ], + }, + ], + latestTurn: { + turnId: TurnId.makeUnsafe("turn-implementation"), + sourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-1"), + planId: "plan-1", + }, + }, + latestTurnSettled: false, + threadId: ThreadId.makeUnsafe("thread-1"), + }), + ).toEqual({ + id: "plan-1", + turnId: "turn-plan", + planMarkdown: "# Source plan", + implementedAt: "2026-02-23T00:00:03.000Z", + implementationThreadId: "thread-2", + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }); + }); + + it("falls back to the latest proposed plan once the turn is settled", () => { + expect( + findSidebarProposedPlan({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.makeUnsafe("turn-plan"), + planMarkdown: "# Older", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }, + { + id: "plan-2", + turnId: TurnId.makeUnsafe("turn-latest"), + planMarkdown: "# Latest", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-23T00:00:03.000Z", + updatedAt: "2026-02-23T00:00:04.000Z", + }, + ], + }, + ], + latestTurn: { + turnId: TurnId.makeUnsafe("turn-implementation"), + sourceProposedPlan: { + threadId: ThreadId.makeUnsafe("thread-1"), + planId: "plan-1", + }, + }, + latestTurnSettled: true, + threadId: ThreadId.makeUnsafe("thread-1"), + })?.planMarkdown, + ).toBe("# Latest"); + }); +}); + describe("deriveWorkLogEntries", () => { it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ @@ -459,6 +557,42 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); + it("omits ExitPlanMode lifecycle entries once the plan card is shown", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "exit-plan-updated", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Tool call", + payload: { + detail: 'ExitPlanMode: {"allowedPrompts":[{"tool":"Bash","prompt":"run tests"}]}', + }, + }), + makeActivity({ + id: "exit-plan-completed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Tool call", + payload: { + detail: "ExitPlanMode: {}", + }, + }), + makeActivity({ + id: "real-work-log", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "Bash: bun test", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]); + }); + it("orders work log by activity sequence when present", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -726,18 +860,18 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { - const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); + it("advertises Claude as available while keeping Cursor as a placeholder", () => { + const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeAgent", label: "Claude", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); expect(claude).toEqual({ - value: "claudeCode", - label: "Claude Code", - available: false, + value: "claudeAgent", + label: "Claude", + available: true, }); expect(cursor).toEqual({ value: "cursor", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 02042acd91..bccdf8642b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -15,11 +15,12 @@ import type { ChatMessage, ProposedPlan, SessionPhase, + Thread, ThreadSession, TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -27,7 +28,7 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, + { value: "claudeAgent", label: "Claude", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; @@ -377,15 +378,7 @@ export function findLatestProposedPlan( ) .at(-1); if (matchingTurnPlan) { - return { - id: matchingTurnPlan.id, - createdAt: matchingTurnPlan.createdAt, - updatedAt: matchingTurnPlan.updatedAt, - turnId: matchingTurnPlan.turnId, - planMarkdown: matchingTurnPlan.planMarkdown, - implementedAt: matchingTurnPlan.implementedAt, - implementationThreadId: matchingTurnPlan.implementationThreadId, - }; + return toLatestProposedPlanState(matchingTurnPlan); } } @@ -399,15 +392,31 @@ export function findLatestProposedPlan( return null; } - return { - id: latestPlan.id, - createdAt: latestPlan.createdAt, - updatedAt: latestPlan.updatedAt, - turnId: latestPlan.turnId, - planMarkdown: latestPlan.planMarkdown, - implementedAt: latestPlan.implementedAt, - implementationThreadId: latestPlan.implementationThreadId, - }; + return toLatestProposedPlanState(latestPlan); +} + +export function findSidebarProposedPlan(input: { + threads: ReadonlyArray>; + latestTurn: Pick | null; + latestTurnSettled: boolean; + threadId: ThreadId | string | null | undefined; +}): LatestProposedPlanState | null { + const activeThreadPlans = + input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; + + if (!input.latestTurnSettled) { + const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; + if (sourceProposedPlan) { + const sourcePlan = input.threads + .find((thread) => thread.id === sourceProposedPlan.threadId) + ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); + if (sourcePlan) { + return toLatestProposedPlanState(sourcePlan); + } + } + } + + return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); } export function hasActionableProposedPlan( @@ -426,6 +435,7 @@ export function deriveWorkLogEntries( .filter((activity) => activity.kind !== "tool.started") .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") .filter((activity) => activity.summary !== "Checkpoint captured") + .filter((activity) => !isPlanBoundaryToolActivity(activity)) .map((activity) => { const payload = activity.payload && typeof activity.payload === "object" @@ -467,6 +477,30 @@ export function deriveWorkLogEntries( }); } +function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + return false; + } + + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); +} + +function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState { + return { + id: proposedPlan.id, + createdAt: proposedPlan.createdAt, + updatedAt: proposedPlan.updatedAt, + turnId: proposedPlan.turnId, + planMarkdown: proposedPlan.planMarkdown, + implementedAt: proposedPlan.implementedAt, + implementationThreadId: proposedPlan.implementationThreadId, + }; +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 92d084f2d6..f1919ec724 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -191,7 +191,7 @@ describe("store pure functions", () => { }); describe("store read model sync", () => { - it("falls back to the codex default for unsupported provider models without an active session", () => { + it("preserves claude model slugs without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ @@ -201,7 +201,29 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); + }); + + it("resolves claude aliases when session provider is claudeAgent", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "sonnet", + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-02-27T00:00:00.000Z", + }, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); }); it("preserves the current project order when syncing incoming read model updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 73f1133130..8269b30a65 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -7,8 +7,7 @@ import { type OrchestrationSessionStatus, } from "@t3tools/contracts"; import { - getModelOptions, - normalizeModelSlug, + inferProviderForModel, resolveModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; @@ -189,26 +188,20 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeAgent") { return providerName; } return "codex"; } -const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); - function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") { return input.sessionProviderName; } - const normalizedCodex = normalizeModelSlug(input.model, "codex"); - if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { - return "codex"; - } - return "codex"; + return inferProviderForModel(input.model); } function resolveWsHttpOrigin(): string { diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe99..b152d82210 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -15,10 +15,7 @@ export default mergeConfig( }, }, test: { - include: [ - "src/components/ChatView.browser.tsx", - "src/components/KeybindingsToast.browser.tsx", - ], + include: ["src/components/**/*.browser.tsx"], browser: { enabled: true, provider: playwright(), diff --git a/bun.lock b/bun.lock index b8e36149f7..38cea36857 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "t3": "./dist/index.mjs", }, "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", @@ -152,6 +153,7 @@ "scripts": { "name": "@t3tools/scripts", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", @@ -182,13 +184,15 @@ "vitest": "^4.0.0", }, "packages": { - "@astrojs/check": ["@astrojs/check@0.9.7", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-dA7U5/OFg8/xaMUb2vUOOJuuJXnMpHy6F0BM8ZhL7WT5OkTBwJ0GoW38n4fC4CXt+lT9mLWL0y8Pa74tFByBpQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], - "@astrojs/compiler": ["@astrojs/compiler@3.0.0", "", {}, "sha512-MwAbDE5mawZ1SS+D8qWiHdprdME5Tlj2e0YjxnEICvcOpbSukNS7Sa7hA5PK+6RrmUr/t6Gi5YgrdZKjbO/WPQ=="], + "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], + + "@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], - "@astrojs/language-server": ["@astrojs/language-server@2.16.4", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.68", "volar-service-emmet": "0.0.68", "volar-service-html": "0.0.68", "volar-service-prettier": "0.0.68", "volar-service-typescript": "0.0.68", "volar-service-typescript-twoslash-queries": "0.0.68", "volar-service-yaml": "0.0.68", "vscode-html-languageservice": "^5.6.1", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-42oqz9uX+hU1/rFniJvtYW9FbfZJ6syM2fYZFi7Ub71/kOvF1GSeMS8sA3Ogs3iOeNUWefk/ImwBiiHeNmJfSA=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.5", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w=="], "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jTAXHPy45L7o1ljH4jYV+ShtOHtyQUa1mGp3a5fJp1soX8lInuTJQ6ihmldHzVM4Q7QptU4SzIDIcKbBJO7sXQ=="], @@ -548,45 +552,45 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.19", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-XxGPKkVW+1t2KJQfgjmSnS+93nI9+ACJl1XjhF3Lo4BdQJOxV3pHeyix31ySn/m/1llq6O/7bXucE0OYCK6Kog=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-wbxrzcmanJuHZb81iir09j42uU9AnKxXDtAuEQJbAnti5f2UfYdCQYejawuHZStFrlsMacCZLh/dDHmqvAaQCw=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], @@ -626,7 +630,7 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="], - "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.0", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-ZkajPtfcBTfLSZTmm40k2E0cwT5HDosBcj71fdUURXl7FfETb50jEbV39ujPw2RFp3ZpOugK3ATdX+HuFfWr9Q=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.1", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-pHDVHqFv26JNC8I500JZ0H4h1kvSyiE3V9gjEO9pRAgD1KrIdJvcHCokV6f7gG7Rx4vMOD11V8VUOpqdyGbKBw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], @@ -700,7 +704,7 @@ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], @@ -710,25 +714,25 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], - "@tanstack/react-router": ["@tanstack/react-router@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1qbSy4r+O7IBdmPLlcKsjB041Gq2MMnIEAYSGIjaMZIL4duUIQnOWLw4jTfjKil/IJz/9rO5JcvrbxOG5UTSdg=="], "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg=="], + "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.166.7", "", { "dependencies": { "@tanstack/router-core": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-lBI0VS7J1zMrJhfvT+3FMq9jPdOrJ3VgciPXyYvZBF/a9Mr8T94MU78PqrBNuJbYh7qCFO14ZhArUFqkYGuozQ=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.166.7", "@tanstack/router-generator": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.166.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-R06qe5UwApb/u02wDITVxN++6QE4xsLFQCr029VZ+4V8gyIe35kr8UCg3Jiyl6D5GXxhj62U2Ei8jccdkQaivw=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.3", "@tanstack/router-generator": "1.166.11", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-PYsnN6goK6zBaVo63UVKjofv69+HHMKRQXymwN55JYKguNnNR8OZ6E12icPb0Olc5uIpPiGz1YI2+rbpmNKGHA=="], - "@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="], "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -786,7 +790,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-Bu5/eP6td3WI654+tRq+ryW1PbgA90y5pqMKpb3U7UpNk6VjI53P/ncPUd192U9dSrepLy7DHnq1XEMDz5H++w=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], @@ -852,7 +856,7 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "astro": ["astro@6.0.4", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-1piLJCPTL/x7AMO2cjVFSTFyRqKuC3W8sSEySCt1aJio+p/wGs5H3K+Xr/rE9ftKtknLUtjxCqCE7/0NsXfGpQ=="], + "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -866,7 +870,7 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -896,7 +900,7 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1156,7 +1160,7 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], + "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -1282,7 +1286,7 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -1406,7 +1410,7 @@ "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], - "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + "msw": ["msw@2.12.11", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-dVg20zi2I2EvnwH/+WupzsOC2mCa7qsIhyMAWtfRikn6RKtwL9+7SaF1IQ5LyZry4tlUtf6KyTVhnlQiZXozTQ=="], "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], @@ -1450,7 +1454,7 @@ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -1458,7 +1462,7 @@ "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], - "oxlint": ["oxlint@1.55.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.55.0", "@oxlint/binding-android-arm64": "1.55.0", "@oxlint/binding-darwin-arm64": "1.55.0", "@oxlint/binding-darwin-x64": "1.55.0", "@oxlint/binding-freebsd-x64": "1.55.0", "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", "@oxlint/binding-linux-arm-musleabihf": "1.55.0", "@oxlint/binding-linux-arm64-gnu": "1.55.0", "@oxlint/binding-linux-arm64-musl": "1.55.0", "@oxlint/binding-linux-ppc64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-musl": "1.55.0", "@oxlint/binding-linux-s390x-gnu": "1.55.0", "@oxlint/binding-linux-x64-gnu": "1.55.0", "@oxlint/binding-linux-x64-musl": "1.55.0", "@oxlint/binding-openharmony-arm64": "1.55.0", "@oxlint/binding-win32-arm64-msvc": "1.55.0", "@oxlint/binding-win32-ia32-msvc": "1.55.0", "@oxlint/binding-win32-x64-msvc": "1.55.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg=="], + "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], @@ -1510,7 +1514,7 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "pure-rand": ["pure-rand@8.0.0", "", {}, "sha512-7rgWlxG2gAvFPIQfUreo1XYlNvrQ9VnQPFWdncPkdl3icucLK0InOxsaafbvxGTnI6Bk/Rxmslg0lQlRCuzOXw=="], + "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -1676,7 +1680,7 @@ "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1684,9 +1688,9 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="], + "tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="], - "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="], + "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1710,19 +1714,19 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="], + "turbo": ["turbo@2.8.17", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.17", "turbo-darwin-arm64": "2.8.17", "turbo-linux-64": "2.8.17", "turbo-linux-arm64": "2.8.17", "turbo-windows-64": "2.8.17", "turbo-windows-arm64": "2.8.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw=="], - "turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="], + "turbo-linux-64": ["turbo-linux-64@2.8.17", "", { "os": "linux", "cpu": "x64" }, "sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg=="], - "turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="], + "turbo-windows-64": ["turbo-windows-64@2.8.17", "", { "os": "win32", "cpu": "x64" }, "sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], @@ -1740,7 +1744,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.24.0", "", {}, "sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg=="], + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -1794,21 +1798,21 @@ "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="], - "vitest-browser-react": ["vitest-browser-react@2.0.5", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YODQX8mHTJCyKNVYTWJrLEYrUtw+QfLl78owgvuE7C5ydgmGBq6v5s4jK2w6wdPhIZsN9PpV1rQbmAevWJjO9g=="], + "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="], - "volar-service-css": ["volar-service-css@0.0.68", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q=="], + "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], - "volar-service-emmet": ["volar-service-emmet@0.0.68", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ=="], + "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], - "volar-service-html": ["volar-service-html@0.0.68", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA=="], + "volar-service-html": ["volar-service-html@0.0.70", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ=="], - "volar-service-prettier": ["volar-service-prettier@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw=="], + "volar-service-prettier": ["volar-service-prettier@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg=="], - "volar-service-typescript": ["volar-service-typescript@0.0.68", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw=="], + "volar-service-typescript": ["volar-service-typescript@0.0.70", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg=="], - "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w=="], + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ=="], - "volar-service-yaml": ["volar-service-yaml@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.19.2" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA=="], + "volar-service-yaml": ["volar-service-yaml@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ=="], "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], @@ -1856,7 +1860,7 @@ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - "yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="], + "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1864,7 +1868,7 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], + "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], @@ -1872,7 +1876,7 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1898,13 +1902,13 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@effect/platform-node/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], + "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], + "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], + "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@effect/vitest/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], + "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -1988,8 +1992,6 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 2b1174e237..dac8ce6ae0 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,8 +1,11 @@ import { Schema } from "effect"; -import { ProviderKind } from "./orchestration"; +import type { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; +export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; +export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; +export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), @@ -10,8 +13,16 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ClaudeModelOptions = Schema.Struct({ + thinking: Schema.optional(Schema.Boolean), + effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), +}); +export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + claudeAgent: Schema.optional(ClaudeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -29,19 +40,28 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; -type BuiltInModelSlug = ModelOptionsByProvider[ProviderKind][number]["slug"]; +type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; export type ModelSlug = BuiltInModelSlug | (string & {}); -export const DEFAULT_MODEL_BY_PROVIDER = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", -} as const satisfies Record; + claudeAgent: "claude-sonnet-4-6", +}; +// Backward compatibility for existing Codex-only call sites. +export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; +export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; -export const MODEL_SLUG_ALIASES_BY_PROVIDER = { +export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: { "5.4": "gpt-5.4", "5.3": "gpt-5.3-codex", @@ -49,12 +69,28 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, -} as const satisfies Record>; + claudeAgent: { + opus: "claude-opus-4-6", + "opus-4.6": "claude-opus-4-6", + "claude-opus-4.6": "claude-opus-4-6", + "claude-opus-4-6-20251117": "claude-opus-4-6", + sonnet: "claude-sonnet-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", + haiku: "claude-haiku-4-5", + "haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4-5-20251001": "claude-haiku-4-5", + }, +}; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, -} as const satisfies Record; + claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, +} as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", -} as const satisfies Record; + claudeAgent: "high", +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 136c5a8493..6fdff4e886 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, OrchestrationGetTurnDiffInput, + OrchestrationLatestTurn, OrchestrationProposedPlan, OrchestrationSession, ProjectCreateCommand, @@ -22,6 +23,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, ); +const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); @@ -247,6 +249,27 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes latest turn source proposed plan metadata when present", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationLatestTurn({ + turnId: "turn-2", + state: "running", + requestedAt: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + sourceProposedPlan: { + threadId: "thread-1", + planId: "plan-1", + }, + }); + assert.deepStrictEqual(parsed.sourceProposedPlan, { + threadId: "thread-1", + planId: "plan-1", + }); + }), +); + it.effect("decodes orchestration session runtime mode defaults", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationSession({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4ee53c2761..3208adc8bb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -43,13 +43,24 @@ export const ProviderSandboxMode = Schema.Literals([ ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; -const CodexProviderStartOptions = Schema.Struct({ + +export const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), }); -const ProviderStartOptions = Schema.Struct({ + +export const ClaudeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + permissionMode: Schema.optional(TrimmedNonEmptyString), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); + +export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + claudeAgent: Schema.optional(ClaudeProviderStartOptions), }); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; + export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -254,6 +265,7 @@ export const OrchestrationLatestTurn = Schema.Struct({ startedAt: Schema.NullOr(IsoDateTime), completedAt: Schema.NullOr(IsoDateTime), assistantMessageId: Schema.NullOr(MessageId), + sourceProposedPlan: Schema.optional(SourceProposedPlanReference), }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b78..5e034f74f2 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -42,6 +42,38 @@ describe("ProviderSessionStartInput", () => { }), ).toThrow(); }); + + it("accepts claude runtime knobs", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "claudeAgent", + cwd: "/tmp/workspace", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + thinking: true, + effort: "max", + fastMode: true, + }, + }, + providerOptions: { + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, + }, + }, + runtimeMode: "full-access", + }); + expect(parsed.provider).toBe("claudeAgent"); + expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true); + expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max"); + expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); + expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); + expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); + expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); + expect(parsed.runtimeMode).toBe("full-access"); + }); }); describe("ProviderSendTurnInput", () => { @@ -61,4 +93,20 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); expect(parsed.modelOptions?.codex?.fastMode).toBe(true); }); + + it("accepts claude provider effort options including ultrathink", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + effort: "ultrathink", + fastMode: true, + }, + }, + }); + + expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink"); + expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9d2a198b6d..db8e24954f 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -19,11 +19,11 @@ import { ProviderKind, ProviderRequestKind, ProviderSandboxMode, + ProviderStartOptions, ProviderUserInputAnswers, RuntimeMode, } from "./orchestration"; -const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const ProviderSessionStatus = Schema.Literals([ "connecting", "ready", @@ -36,32 +36,22 @@ export const ProviderSession = Schema.Struct({ provider: ProviderKind, status: ProviderSessionStatus, runtimeMode: RuntimeMode, - cwd: Schema.optional(TrimmedNonEmptyStringSchema), - model: Schema.optional(TrimmedNonEmptyStringSchema), + cwd: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), activeTurnId: Schema.optional(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, - lastError: Schema.optional(TrimmedNonEmptyStringSchema), + lastError: Schema.optional(TrimmedNonEmptyString), }); export type ProviderSession = typeof ProviderSession.Type; -const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - homePath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -export const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), -}); -export type ProviderStartOptions = typeof ProviderStartOptions.Type; - export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), - cwd: Schema.optional(TrimmedNonEmptyStringSchema), - model: Schema.optional(TrimmedNonEmptyStringSchema), + cwd: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), modelOptions: Schema.optional(ProviderModelOptions), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), @@ -74,12 +64,12 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ threadId: ThreadId, input: Schema.optional( - TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), + TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), attachments: Schema.optional( Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), ), - model: Schema.optional(TrimmedNonEmptyStringSchema), + model: Schema.optional(TrimmedNonEmptyString), modelOptions: Schema.optional(ProviderModelOptions), interactionMode: Schema.optional(ProviderInteractionMode), }); @@ -125,8 +115,8 @@ export const ProviderEvent = Schema.Struct({ provider: ProviderKind, threadId: ThreadId, createdAt: IsoDateTime, - method: TrimmedNonEmptyStringSchema, - message: Schema.optional(TrimmedNonEmptyStringSchema), + method: TrimmedNonEmptyString, + message: Schema.optional(TrimmedNonEmptyString), turnId: Schema.optional(TurnId), itemId: Schema.optional(ProviderItemId), requestId: Schema.optional(ApprovalRequestId), diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 8dad509aee..3dc0091add 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -10,7 +10,7 @@ describe("ProviderRuntimeEvent", () => { const parsed = decodeRuntimeEvent({ type: "turn.plan.updated", eventId: "event-1", - provider: "codex", + provider: "claudeAgent", sessionId: "runtime-session-1", createdAt: "2026-02-28T00:00:00.000Z", threadId: "thread-1", @@ -56,7 +56,7 @@ describe("ProviderRuntimeEvent", () => { const parsed = decodeRuntimeEvent({ type: "user-input.requested", eventId: "event-2", - provider: "codex", + provider: "claudeAgent", sessionId: "runtime-session-2", createdAt: "2026-02-28T00:00:01.000Z", threadId: "thread-2", @@ -94,7 +94,7 @@ describe("ProviderRuntimeEvent", () => { const parsed = decodeRuntimeEvent({ type: "user-input.resolved", eventId: "event-3", - provider: "codex", + provider: "claudeAgent", sessionId: "runtime-session-2", createdAt: "2026-02-28T00:00:02.000Z", threadId: "thread-2", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index d76475ab5a..2fec889b6d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Option, Schema } from "effect"; import { EventId, IsoDateTime, @@ -19,6 +19,8 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.notification", "codex.app-server.request", "codex.eventmsg", + "claude.sdk.message", + "claude.sdk.permission", "codex.sdk.thread-event", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; @@ -411,6 +413,9 @@ export const UserInputQuestion = Schema.Struct({ header: TrimmedNonEmptyStringSchema, question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), + multiSelect: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), }); export type UserInputQuestion = typeof UserInputQuestion.Type; @@ -434,6 +439,7 @@ export type TaskStartedPayload = typeof TaskStartedPayload.Type; const TaskProgressPayload = Schema.Struct({ taskId: RuntimeTaskId, description: TrimmedNonEmptyStringSchema, + summary: Schema.optional(TrimmedNonEmptyStringSchema), usage: Schema.optional(Schema.Unknown), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1c..be1009c5e1 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,13 +1,33 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_MODEL_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_OPTIONS, + MODEL_OPTIONS_BY_PROVIDER, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, +} from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, + getEffectiveClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, getModelOptions, getReasoningEffortOptions, + inferProviderForModel, + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, normalizeModelSlug, + resolveReasoningEffortForProvider, resolveModelSlug, + resolveModelSlugForProvider, + supportsClaudeAdaptiveReasoning, + supportsClaudeFastMode, + supportsClaudeMaxEffort, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, } from "./model"; describe("normalizeModelSlug", () => { @@ -32,38 +52,230 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug("toString")).toBe("toString"); expect(normalizeModelSlug("constructor")).toBe("constructor"); }); + + it("uses provider-specific aliases", () => { + expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); + expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); + expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); + }); }); describe("resolveModelSlug", () => { it("returns default only when the model is missing", () => { - expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); - expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); + expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); }); it("preserves unknown custom models", () => { - expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); - expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); + expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); }); it("resolves only supported model options", () => { - for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { + for (const model of MODEL_OPTIONS) { expect(resolveModelSlug(model.slug)).toBe(model.slug); } }); + + it("supports provider-aware resolution", () => { + expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( + DEFAULT_MODEL_BY_PROVIDER.claudeAgent, + ); + expect(resolveModelSlugForProvider("claudeAgent", "sonnet")).toBe("claude-sonnet-4-6"); + expect(resolveModelSlugForProvider("claudeAgent", "gpt-5.3-codex")).toBe( + DEFAULT_MODEL_BY_PROVIDER.claudeAgent, + ); + }); + it("keeps codex defaults for backward compatibility", () => { - expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); - expect(getModelOptions()).toEqual(MODEL_OPTIONS_BY_PROVIDER.codex); + expect(getDefaultModel()).toBe(DEFAULT_MODEL); + expect(getModelOptions()).toEqual(MODEL_OPTIONS); + expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); }); }); describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { - expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]); + expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex); + }); + + it("returns claude effort options for Opus 4.6", () => { + expect(getReasoningEffortOptions("claudeAgent", "claude-opus-4-6")).toEqual([ + "low", + "medium", + "high", + "max", + "ultrathink", + ]); + }); + + it("returns claude effort options for Sonnet 4.6", () => { + expect(getReasoningEffortOptions("claudeAgent", "claude-sonnet-4-6")).toEqual([ + "low", + "medium", + "high", + "ultrathink", + ]); + }); + + it("returns no claude effort options for Haiku 4.5", () => { + expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]); + }); +}); + +describe("inferProviderForModel", () => { + it("detects known provider model slugs", () => { + expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); + expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); + expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); + }); + + it("falls back when the model is unknown", () => { + expect(inferProviderForModel("custom/internal-model")).toBe("codex"); + expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); + }); + + it("treats claude-prefixed custom slugs as claude", () => { + expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); }); }); describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { - expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); + expect(getDefaultReasoningEffort("claudeAgent")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, + ); + }); +}); + +describe("resolveReasoningEffortForProvider", () => { + it("accepts provider-scoped effort values", () => { + expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); + expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); + }); + + it("rejects effort values from the wrong provider", () => { + expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); + expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); + }); +}); + +describe("applyClaudePromptEffortPrefix", () => { + it("prefixes ultrathink prompts exactly once", () => { + expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe( + "Ultrathink:\nInvestigate this", + ); + expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe( + "Ultrathink:\nInvestigate this", + ); + }); + + it("leaves non-ultrathink prompts unchanged", () => { + expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this"); + }); +}); + +describe("getEffectiveClaudeCodeEffort", () => { + it("does not persist ultrathink into Claude runtime configuration", () => { + expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); + expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); + }); + + it("returns null when no claude effort is selected", () => { + expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); + expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); + }); +}); + +describe("normalizeCodexModelOptions", () => { + it("drops default-only codex options", () => { + expect( + normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), + ).toBeUndefined(); + }); + + it("preserves non-default codex options", () => { + expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ + reasoningEffort: "xhigh", + fastMode: true, + }); + }); +}); + +describe("normalizeClaudeModelOptions", () => { + it("drops unsupported fast mode and max effort for Sonnet", () => { + expect( + normalizeClaudeModelOptions("claude-sonnet-4-6", { + effort: "max", + fastMode: true, + }), + ).toBeUndefined(); + }); + + it("keeps the Haiku thinking toggle and removes unsupported effort", () => { + expect( + normalizeClaudeModelOptions("claude-haiku-4-5", { + thinking: false, + effort: "high", + }), + ).toEqual({ + thinking: false, + }); + }); +}); + +describe("supportsClaudeAdaptiveReasoning", () => { + it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeMaxEffort", () => { + it("only enables max effort for Opus 4.6", () => { + expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); + expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeMaxEffort(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeFastMode", () => { + it("only enables Claude fast mode for Opus 4.6", () => { + expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); + expect(supportsClaudeFastMode("opus")).toBe(true); + expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeFastMode(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeUltrathinkKeyword", () => { + it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); + }); +}); + +describe("supportsClaudeThinkingToggle", () => { + it("only enables the Claude thinking toggle for Haiku 4.5", () => { + expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); + expect(supportsClaudeThinkingToggle("haiku")).toBe(true); + expect(supportsClaudeThinkingToggle(undefined)).toBe(false); + }); +}); + +describe("isClaudeUltrathinkPrompt", () => { + it("detects ultrathink prompts case-insensitively", () => { + expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); + expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); + expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false); + expect(isClaudeUltrathinkPrompt(undefined)).toBe(false); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9f..625dfd5adf 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,19 +1,29 @@ import { + CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, + type ClaudeModelOptions, + type ClaudeCodeEffort, + type CodexModelOptions, type CodexReasoningEffort, type ModelSlug, + type ProviderReasoningEffort, type ProviderKind, } from "@t3tools/contracts"; -type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; - -const MODEL_SLUG_SET_BY_PROVIDER: Record> = { +const MODEL_SLUG_SET_BY_PROVIDER: Record> = { + claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), }; +const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; +const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; +const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; + export function getModelOptions(provider: ProviderKind = "codex") { return MODEL_OPTIONS_BY_PROVIDER[provider]; } @@ -22,6 +32,31 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { return DEFAULT_MODEL_BY_PROVIDER[provider]; } +export function supportsClaudeFastMode(model: string | null | undefined): boolean { + return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { + const normalized = normalizeModelSlug(model, "claudeAgent"); + return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL; +} + +export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { + return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { + return supportsClaudeAdaptiveReasoning(model); +} + +export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { + return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL; +} + +export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { + return typeof text === "string" && /\bultrathink\b/i.test(text); +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderKind = "codex", @@ -36,7 +71,9 @@ export function normalizeModelSlug( } const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; - const aliased = aliases[trimmed]; + const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed) + ? aliases[trimmed] + : undefined; return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); } @@ -46,12 +83,12 @@ export function resolveModelSlug( ): ModelSlug { const normalized = normalizeModelSlug(model, provider); if (!normalized) { - return getDefaultModel(provider); + return DEFAULT_MODEL_BY_PROVIDER[provider]; } return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) ? normalized - : getDefaultModel(provider); + : DEFAULT_MODEL_BY_PROVIDER[provider]; } export function resolveModelSlugForProvider( @@ -61,18 +98,151 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } +export function inferProviderForModel( + model: string | null | undefined, + fallback: ProviderKind = "codex", +): ProviderKind { + const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); + if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { + return "claudeAgent"; + } + + const normalizedCodex = normalizeModelSlug(model, "codex"); + if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { + return "codex"; + } + + return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; +} + +export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; +export function getReasoningEffortOptions( + provider: "claudeAgent", + model?: string | null | undefined, +): ReadonlyArray; +export function getReasoningEffortOptions( + provider?: ProviderKind, + model?: string | null | undefined, +): ReadonlyArray; export function getReasoningEffortOptions( provider: ProviderKind = "codex", -): ReadonlyArray { - return provider === "codex" ? CODEX_REASONING_EFFORT_OPTIONS : []; + model?: string | null | undefined, +): ReadonlyArray { + if (provider === "claudeAgent") { + if (supportsClaudeMaxEffort(model)) { + return ["low", "medium", "high", "max", "ultrathink"]; + } + if (supportsClaudeAdaptiveReasoning(model)) { + return ["low", "medium", "high", "ultrathink"]; + } + return []; + } + return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; } export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; -export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null; +export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; +export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; export function getDefaultReasoningEffort( provider: ProviderKind = "codex", -): CodexReasoningEffort | null { - return provider === "codex" ? "high" : null; +): ProviderReasoningEffort { + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; +} + +export function resolveReasoningEffortForProvider( + provider: "codex", + effort: string | null | undefined, +): CodexReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: "claudeAgent", + effort: string | null | undefined, +): ClaudeCodeEffort | null; +export function resolveReasoningEffortForProvider( + provider: ProviderKind, + effort: string | null | undefined, +): ProviderReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: ProviderKind, + effort: string | null | undefined, +): ProviderReasoningEffort | null { + if (typeof effort !== "string") { + return null; + } + + const trimmed = effort.trim(); + if (!trimmed) { + return null; + } + + const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; + return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null; +} + +export function getEffectiveClaudeCodeEffort( + effort: ClaudeCodeEffort | null | undefined, +): Exclude | null { + if (!effort) { + return null; + } + return effort === "ultrathink" ? null : effort; +} + +export function normalizeCodexModelOptions( + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningEffort = + resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); + const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const effort = + resolvedEffort && + resolvedEffort !== "ultrathink" && + reasoningOptions.includes(resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; + const fastMode = + supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function applyClaudePromptEffortPrefix( + text: string, + effort: ClaudeCodeEffort | null | undefined, +): string { + const trimmed = text.trim(); + if (!trimmed) { + return trimmed; + } + if (effort !== "ultrathink") { + return trimmed; + } + if (trimmed.startsWith("Ultrathink:")) { + return trimmed; + } + return `Ultrathink:\n${trimmed}`; } -export { CODEX_REASONING_EFFORT_OPTIONS }; +export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS }; diff --git a/scripts/claude-fast-mode-probe.ts b/scripts/claude-fast-mode-probe.ts new file mode 100644 index 0000000000..272cc743f1 --- /dev/null +++ b/scripts/claude-fast-mode-probe.ts @@ -0,0 +1,129 @@ +import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +type ProbeMode = "off" | "on"; + +async function* emptyPrompt(): AsyncGenerator {} + +function parseArgs(argv: ReadonlyArray): { + readonly mode: ProbeMode | "both"; + readonly model?: string; + readonly cwd?: string; + readonly prompt?: string; +} { + let mode: ProbeMode | "both" = "both"; + let model: string | undefined; + let cwd: string | undefined; + let prompt: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value) { + continue; + } + if (value === "--mode") { + const next = argv[index + 1]; + if (next === "off" || next === "on" || next === "both") { + mode = next; + index += 1; + } + continue; + } + if (value === "--model") { + model = argv[index + 1]; + index += 1; + continue; + } + if (value === "--cwd") { + cwd = argv[index + 1]; + index += 1; + continue; + } + if (value === "--prompt") { + prompt = argv[index + 1] ?? prompt; + index += 1; + } + } + + return { + mode, + ...(model ? { model } : {}), + ...(cwd ? { cwd } : {}), + ...(prompt ? { prompt } : {}), + }; +} + +async function runProbe(input: { + readonly mode: ProbeMode; + readonly model?: string; + readonly cwd?: string; + readonly prompt?: string; +}): Promise { + const messages = query({ + prompt: input.prompt ?? emptyPrompt(), + options: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + persistSession: false, + tools: [], + permissionMode: "plan", + includePartialMessages: true, + ...(input.mode === "on" ? { settings: { fastMode: true } } : {}), + }, + }); + + const summary = { + mode: input.mode, + initFastModeState: null as string | null, + resultFastModeState: null as string | null, + resultSubtype: null as string | null, + resultText: null as string | null, + }; + + const initialization = await messages.initializationResult(); + summary.initFastModeState = initialization.fast_mode_state ?? null; + + if (!input.prompt) { + messages.close(); + console.log(JSON.stringify(summary, null, 2)); + return; + } + + for await (const message of messages) { + handleProbeMessage(message, summary); + } + console.log(JSON.stringify(summary, null, 2)); +} + +function handleProbeMessage( + message: SDKMessage, + summary: { + mode: ProbeMode; + initFastModeState: string | null; + resultFastModeState: string | null; + resultSubtype: string | null; + resultText: string | null; + }, +): void { + if (message.type !== "result") { + return; + } + summary.resultSubtype = message.subtype; + summary.resultFastModeState = message.fast_mode_state ?? null; + summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const modes = args.mode === "both" ? (["off", "on"] as const) : [args.mode]; + + for (const mode of modes) { + await runProbe({ + mode, + ...(args.model ? { model: args.model } : {}), + ...(args.cwd ? { cwd: args.cwd } : {}), + ...(args.prompt ? { prompt: args.prompt } : {}), + }); + } +} + +await main(); diff --git a/scripts/claude-haiku-thinking-probe.ts b/scripts/claude-haiku-thinking-probe.ts new file mode 100644 index 0000000000..8254e9d3dc --- /dev/null +++ b/scripts/claude-haiku-thinking-probe.ts @@ -0,0 +1,261 @@ +import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; + +type ProbeCase = + | "default" + | "effort-low" + | "effort-high" + | "settings-off" + | "settings-on" + | "thinking-disabled" + | "thinking-enabled"; + +function parseArgs(argv: ReadonlyArray): { + readonly model: string; + readonly cwd?: string; + readonly prompt: string; + readonly cases: ReadonlyArray; +} { + let model = "claude-haiku-4-5"; + let cwd: string | undefined; + let prompt = + "Think step by step about this before answering: what is 27 multiplied by 43? Return the final number only."; + let cases: ReadonlyArray = [ + "default", + "effort-low", + "effort-high", + "settings-off", + "settings-on", + "thinking-disabled", + "thinking-enabled", + ]; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value) { + continue; + } + if (value === "--model") { + model = argv[index + 1] ?? model; + index += 1; + continue; + } + if (value === "--cwd") { + cwd = argv[index + 1] ?? cwd; + index += 1; + continue; + } + if (value === "--prompt") { + prompt = argv[index + 1] ?? prompt; + index += 1; + continue; + } + if (value === "--cases") { + const next = argv[index + 1]; + if (next) { + const requested = next + .split(",") + .map((entry) => entry.trim()) + .filter( + (entry): entry is ProbeCase => + entry === "default" || + entry === "effort-low" || + entry === "effort-high" || + entry === "settings-off" || + entry === "settings-on" || + entry === "thinking-disabled" || + entry === "thinking-enabled", + ); + if (requested.length > 0) { + cases = requested; + } + } + index += 1; + } + } + + return { + model, + prompt, + ...(cwd ? { cwd } : {}), + cases, + }; +} + +function caseOptions(caseName: ProbeCase): { + readonly label: ProbeCase; + readonly effort?: "low" | "high"; + readonly settings?: { alwaysThinkingEnabled: boolean }; + readonly thinking?: ThinkingConfig; +} { + switch (caseName) { + case "effort-low": + return { + label: caseName, + effort: "low", + }; + case "effort-high": + return { + label: caseName, + effort: "high", + }; + case "settings-off": + return { + label: caseName, + settings: { + alwaysThinkingEnabled: false, + }, + }; + case "settings-on": + return { + label: caseName, + settings: { + alwaysThinkingEnabled: true, + }, + }; + case "thinking-disabled": + return { + label: caseName, + thinking: { + type: "disabled", + }, + }; + case "thinking-enabled": + return { + label: caseName, + thinking: { + type: "enabled", + budgetTokens: 1024, + }, + }; + default: + return { + label: caseName, + }; + } +} + +type ProbeSummary = { + readonly case: ProbeCase; + readonly model: string; + initModelInfo: { + value: string | null; + supportsEffort: boolean | null; + supportsAdaptiveThinking: boolean | null; + supportedEffortLevels: ReadonlyArray | null; + } | null; + resultSubtype: string | null; + resultText: string | null; + assistantThinkingBlockCount: number; + streamThinkingEventTypes: Record; + error: string | null; +}; + +async function runCase(input: { + readonly caseName: ProbeCase; + readonly model: string; + readonly cwd?: string; + readonly prompt: string; +}): Promise { + const caseConfig = caseOptions(input.caseName); + const summary: ProbeSummary = { + case: input.caseName, + model: input.model, + initModelInfo: null, + resultSubtype: null, + resultText: null, + assistantThinkingBlockCount: 0, + streamThinkingEventTypes: {}, + error: null, + }; + + try { + const messages = query({ + prompt: input.prompt, + options: { + model: input.model, + ...(input.cwd ? { cwd: input.cwd } : {}), + persistSession: false, + tools: [], + permissionMode: "bypassPermissions", + includePartialMessages: true, + maxTurns: 1, + ...(caseConfig.effort ? { effort: caseConfig.effort } : {}), + ...(caseConfig.settings ? { settings: caseConfig.settings } : {}), + ...(caseConfig.thinking ? { thinking: caseConfig.thinking } : {}), + }, + }); + + const initialization = await messages.initializationResult(); + const initModel = + initialization.models.find((candidate) => candidate.value === input.model) ?? + initialization.models.find((candidate) => candidate.value.includes("haiku")) ?? + null; + summary.initModelInfo = initModel + ? { + value: initModel.value, + supportsEffort: initModel.supportsEffort ?? null, + supportsAdaptiveThinking: initModel.supportsAdaptiveThinking ?? null, + supportedEffortLevels: initModel.supportedEffortLevels ?? null, + } + : null; + + for await (const message of messages) { + handleMessage(summary, message); + } + } catch (error) { + summary.error = error instanceof Error ? error.message : String(error); + } + + return summary; +} + +function handleMessage(summary: ProbeSummary, message: SDKMessage): void { + if (message.type === "stream_event") { + const event = message.event as { + type?: string; + delta?: { + type?: string; + }; + }; + const eventType = event.delta?.type ?? event.type; + if (typeof eventType === "string" && eventType.includes("thinking")) { + summary.streamThinkingEventTypes[eventType] = + (summary.streamThinkingEventTypes[eventType] ?? 0) + 1; + } + return; + } + + if (message.type === "assistant") { + for (const block of message.message.content) { + if (typeof block?.type === "string" && block.type.includes("thinking")) { + summary.assistantThinkingBlockCount += 1; + } + } + return; + } + + if (message.type === "result") { + summary.resultSubtype = message.subtype; + summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const results: ProbeSummary[] = []; + + for (const caseName of args.cases) { + results.push( + await runCase({ + caseName, + model: args.model, + ...(args.cwd ? { cwd: args.cwd } : {}), + prompt: args.prompt, + }), + ); + } + + console.log(JSON.stringify(results, null, 2)); +} + +await main(); diff --git a/scripts/package.json b/scripts/package.json index 1e45e6ed9e..d9db897ea2 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,10 +4,13 @@ "type": "module", "scripts": { "prepare": "effect-language-service patch", + "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", + "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*",