diff --git a/.docs/provider-architecture.md b/.docs/provider-architecture.md index 794b6aa5d5..9c140b2f4d 100644 --- a/.docs/provider-architecture.md +++ b/.docs/provider-architecture.md @@ -13,7 +13,7 @@ Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: - `providers.respondToRequest`, `providers.stopSession` - `shell.openInEditor`, `server.getConfig` -Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. +Codex is the only implemented provider. `claudeAgent` is reserved in contracts/UI. ## Client transport diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f3ead16e1..0db4aa5658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,7 +283,7 @@ jobs: name: Mint release app token uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.RELEASE_APP_ID }} + app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} 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/.plans/17-claude-code.md b/.plans/17-claude-code.md index 822dbd806b..c697011d3c 100644 --- a/.plans/17-claude-code.md +++ b/.plans/17-claude-code.md @@ -1,6 +1,6 @@ # Plan: Claude Code Integration (Orchestration Architecture) -> **Note -- Multi-provider scope:** This plan was originally written for the Claude Code adapter, but the patterns described here (adapter shape, canonical runtime mapping, resume cursor ownership, provider registry wiring, and orchestration integration) apply equally to the full multi-provider adapter infrastructure now implemented in this PR: **ClaudeCodeAdapter**, **CopilotAdapter**, **OpenCodeAdapter**, **GeminiCliAdapter**, **KiloAdapter**, and **AmpAdapter**. Where the text says "Claude adapter", read it as the reference implementation; every other adapter follows the same contract surface. +> **Note -- Multi-provider scope:** This plan was originally written for the Claude Code adapter, but the patterns described here (adapter shape, canonical runtime mapping, resume cursor ownership, provider registry wiring, and orchestration integration) apply equally to the full multi-provider adapter infrastructure now implemented in this PR: **ClaudeAdapter**, **CopilotAdapter**, **OpenCodeAdapter**, **GeminiCliAdapter**, **KiloAdapter**, and **AmpAdapter**. Where the text says "Claude adapter", read it as the reference implementation; every other adapter follows the same contract surface. ## Why this plan was rewritten @@ -90,8 +90,8 @@ Update/add tests in `packages/contracts/src/*.test.ts` for: Create: -1. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` -2. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` +1. `apps/server/src/provider/Services/ClaudeAdapter.ts` +2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` Adapter must implement `ProviderAdapterShape`. @@ -181,7 +181,7 @@ const acquireSession = (input: ProviderSessionStartInput) => }, catch: (cause) => new ProviderAdapterProcessError({ - provider: "claudeCode", + provider: "claudeAgent", sessionId: "pending", detail: "Failed to start Claude runtime session.", cause, @@ -200,7 +200,7 @@ const sdkMessageStream = Stream.fromAsyncIterable( session.result, (cause) => new ProviderAdapterProcessError({ - provider: "claudeCode", + provider: "claudeAgent", sessionId, detail: "Claude runtime stream failed.", cause, @@ -223,7 +223,7 @@ const sdkMessageStream = Stream.async `ClaudeCodeAdapter` +1. add `claudeAgent` -> `ClaudeAdapter` 2. ensure `ProviderService.listProviderStatuses()` reports Claude availability ### 3.2 Persist provider binding @@ -309,7 +309,7 @@ Required validation: Update `ProviderCommandReactor` / orchestration flow: -1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. +1. If a thread turn start requests `provider: "claudeAgent"`, 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. @@ -405,7 +405,7 @@ Cover: ### 6.2 Adapter layer tests -Add `ClaudeCodeAdapter.test.ts` covering: +Add `ClaudeAdapter.test.ts` covering: 1. session start 2. event mapping diff --git a/AGENTS.md b/AGENTS.md index deb69993c5..59dc4f8cd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,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/apps/desktop/package.json b/apps/desktop/package.json index a955b4297b..06ce46c94c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.5", + "version": "0.0.13", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 642f9b3dec..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" | "claudeCode"; + readonly provider?: ProviderKind; readonly realCodex?: boolean; } diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 76a9cb8973..187f471866 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"; @@ -36,7 +37,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex" | "claudeCode" | "cursor"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -178,7 +179,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex" | "claudeCode"; + readonly provider: ProviderKind; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -198,7 +199,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex" | "claudeCode"; + readonly provider?: ProviderKind; } function nowIso(): string { @@ -206,7 +207,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex" | "claudeCode", + provider: ProviderKind, threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -216,7 +217,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex" | "claudeCode", + 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 5223ead40b..2c116751e6 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" | "claudeCode"; +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, @@ -883,7 +887,7 @@ it.live( ), ); -it.live("starts a claudeCode session on first turn when provider is requested", () => +it.live("starts a claudeAgent session on first turn when provider is requested", () => withHarness( (harness) => Effect.gen(function* () { @@ -893,20 +897,20 @@ it.live("starts a claudeCode session on first turn when provider is requested", events: [ { type: "turn.started", - ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -919,25 +923,25 @@ it.live("starts a claudeCode session on first turn when provider is requested", commandId: "cmd-turn-start-claude-initial", messageId: "msg-user-claude-initial", text: "Use Claude", - provider: "claudeCode", + provider: "claudeAgent", }); const thread = yield* harness.waitForThread( THREAD_ID, (entry) => - entry.session?.providerName === "claudeCode" && + 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, "claudeCode"); + assert.equal(thread.session?.providerName, "claudeAgent"); }), - "claudeCode", + "claudeAgent", ), ); -it.live("recovers claudeCode sessions after provider stopAll using persisted resume state", () => +it.live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () => withHarness( (harness) => Effect.gen(function* () { @@ -947,20 +951,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -973,7 +977,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res commandId: "cmd-turn-start-claude-recover-1", messageId: "msg-user-claude-recover-1", text: "Before restart", - provider: "claudeCode", + provider: "claudeAgent", }); yield* harness.waitForThread( @@ -993,20 +997,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res events: [ { type: "turn.started", - ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1029,20 +1033,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res const recoveredThread = yield* harness.waitForThread( THREAD_ID, (entry) => - entry.session?.providerName === "claudeCode" && + 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, "claudeCode"); + assert.equal(recoveredThread.session?.providerName, "claudeAgent"); assert.equal(recoveredThread.session?.threadId, "thread-1"); }), - "claudeCode", + "claudeAgent", ), ); -it.live("forwards claudeCode approval responses to the provider session", () => +it.live("forwards claudeAgent approval responses to the provider session", () => withHarness( (harness) => Effect.gen(function* () { @@ -1052,13 +1056,13 @@ it.live("forwards claudeCode approval responses to the provider session", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, @@ -1067,7 +1071,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => }, { type: "turn.completed", - ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1080,7 +1084,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => commandId: "cmd-turn-start-claude-approval", messageId: "msg-user-claude-approval", text: "Need approval", - provider: "claudeCode", + provider: "claudeAgent", }); const thread = yield* harness.waitForThread(THREAD_ID, (entry) => @@ -1109,11 +1113,11 @@ it.live("forwards claudeCode approval responses to the provider session", () => ); assert.equal(approvalResponses[0]?.decision, "accept"); }), - "claudeCode", + "claudeAgent", ), ); -it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => +it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => withHarness( (harness) => Effect.gen(function* () { @@ -1123,20 +1127,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => events: [ { type: "turn.started", - ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1149,7 +1153,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => commandId: "cmd-turn-start-claude-interrupt", messageId: "msg-user-claude-interrupt", text: "Start long turn", - provider: "claudeCode", + provider: "claudeAgent", }); const thread = yield* harness.waitForThread( @@ -1175,11 +1179,11 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => ); assert.equal(interruptCalls.length, 1); }), - "claudeCode", + "claudeAgent", ), ); -it.live("reverts claudeCode turns and rolls back provider conversation state", () => +it.live("reverts claudeAgent turns and rolls back provider conversation state", () => withHarness( (harness) => Effect.gen(function* () { @@ -1189,20 +1193,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1219,7 +1223,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( commandId: "cmd-turn-start-claude-revert-1", messageId: "msg-user-claude-revert-1", text: "First Claude edit", - provider: "claudeCode", + provider: "claudeAgent", }); yield* harness.waitForThread( @@ -1232,20 +1236,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( events: [ { type: "turn.started", - ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + ...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", "claudeCode"), + ...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", "claudeCode"), + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", @@ -1269,7 +1273,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( (entry) => entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && - entry.session?.providerName === "claudeCode", + entry.session?.providerName === "claudeAgent", ); yield* harness.engine.dispatch({ @@ -1296,6 +1300,6 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( ); assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); }), - "claudeCode", + "claudeAgent", ), ); diff --git a/apps/server/package.json b/apps/server/package.json index 093e4b69a2..bf2011b4ce 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.5", + "version": "0.0.13", "repository": { "type": "git", "url": "https://github.com/pingdotgg/t3code", @@ -22,7 +22,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.71", + "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@github/copilot": "1.0.2", diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 2dfe4689d7..ad33503e4d 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -37,6 +37,7 @@ function createSendTurnHarness() { planType: null, sparkEnabled: true, }, + collabReceiverTurns: new Map(), }; const requireSession = vi @@ -75,6 +76,7 @@ function createThreadControlHarness() { createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, + collabReceiverTurns: new Map(), }; const requireSession = vi @@ -146,6 +148,7 @@ function createPendingUserInputHarness() { }, ], ]), + collabReceiverTurns: new Map(), }; const requireSession = vi @@ -164,6 +167,43 @@ function createPendingUserInputHarness() { return { manager, context, requireSession, writeMessage, emitEvent }; } +function createCollabNotificationHarness() { + const manager = new CodexAppServerManager(); + const context = { + session: { + provider: "codex", + status: "running", + threadId: asThreadId("thread_1"), + runtimeMode: "full-access", + model: "gpt-5.3-codex", + activeTurnId: "turn_parent", + resumeCursor: { threadId: "provider_parent" }, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), + nextRequestId: 1, + stopping: false, + }; + + const emitEvent = vi + .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .mockImplementation(() => {}); + const updateSession = vi + .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") + .mockImplementation(() => {}); + + return { manager, context, emitEvent, updateSession }; +} + describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); @@ -784,6 +824,7 @@ describe("respondToUserInput", () => { }, pendingApprovals: new Map(), pendingUserInputs: new Map(), + collabReceiverTurns: new Map(), }; type ApprovalRequestContext = { session: typeof context.session; @@ -811,6 +852,152 @@ describe("respondToUserInput", () => { }); }); +describe("collab child conversation routing", () => { + it("rewrites child notification turn ids onto the parent turn", () => { + const { manager, context, emitEvent } = createCollabNotificationHarness(); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "item/completed", + params: { + item: { + type: "collabAgentToolCall", + id: "call_collab_1", + receiverThreadIds: ["child_provider_1"], + }, + threadId: "provider_parent", + turnId: "turn_parent", + }, + }); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "item/agentMessage/delta", + params: { + threadId: "child_provider_1", + turnId: "turn_child_1", + itemId: "msg_child_1", + delta: "working", + }, + }); + + expect(emitEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "item/agentMessage/delta", + turnId: "turn_parent", + itemId: "msg_child_1", + }), + ); + }); + + it("suppresses child lifecycle notifications so they cannot replace the parent turn", () => { + const { manager, context, emitEvent, updateSession } = createCollabNotificationHarness(); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "item/completed", + params: { + item: { + type: "collabAgentToolCall", + id: "call_collab_1", + receiverThreadIds: ["child_provider_1"], + }, + threadId: "provider_parent", + turnId: "turn_parent", + }, + }); + emitEvent.mockClear(); + updateSession.mockClear(); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "turn/started", + params: { + threadId: "child_provider_1", + turn: { id: "turn_child_1" }, + }, + }); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "turn/completed", + params: { + threadId: "child_provider_1", + turn: { id: "turn_child_1", status: "completed" }, + }, + }); + + expect(emitEvent).not.toHaveBeenCalled(); + expect(updateSession).not.toHaveBeenCalled(); + }); + + it("rewrites child approval requests onto the parent turn", () => { + const { manager, context, emitEvent } = createCollabNotificationHarness(); + + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, { + method: "item/completed", + params: { + item: { + type: "collabAgentToolCall", + id: "call_collab_1", + receiverThreadIds: ["child_provider_1"], + }, + threadId: "provider_parent", + turnId: "turn_parent", + }, + }); + emitEvent.mockClear(); + + ( + manager as unknown as { + handleServerRequest: (context: unknown, request: Record) => void; + } + ).handleServerRequest(context, { + id: 42, + method: "item/commandExecution/requestApproval", + params: { + threadId: "child_provider_1", + turnId: "turn_child_1", + itemId: "call_child_1", + command: "bun install", + }, + }); + + expect(Array.from(context.pendingApprovals.values())[0]).toEqual( + expect.objectContaining({ + turnId: "turn_parent", + itemId: "call_child_1", + }), + ); + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + method: "item/commandExecution/requestApproval", + turnId: "turn_parent", + itemId: "call_child_1", + }), + ); + }); +}); + describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { it("keeps prior thread history when resuming with a changed runtime mode", async () => { const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index e9a99f720d..2adec89eb4 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -71,6 +71,7 @@ interface CodexSessionContext { pending: Map; pendingApprovals: Map; pendingUserInputs: Map; + collabReceiverTurns: Map; nextRequestId: number; stopping: boolean; } @@ -573,6 +574,7 @@ export class CodexAppServerManager extends EventEmitter { const context = this.requireSession(input.threadId); + context.collabReceiverTurns.clear(); const turnInput: Array< { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } @@ -1172,7 +1175,16 @@ export class CodexAppServerManager extends EventEmitter (typeof value === "string" ? value : null)) + .filter((value): value is string => value !== null) ?? []; + for (const receiverThreadId of receiverThreadIds) { + context.collabReceiverTurns.set(receiverThreadId, parentTurnId); + } + } + + private shouldSuppressChildConversationNotification(method: string): boolean { + return ( + method === "thread/started" || + method === "thread/status/changed" || + method === "thread/archived" || + method === "thread/unarchived" || + method === "thread/closed" || + method === "thread/compacted" || + method === "thread/name/updated" || + method === "thread/tokenUsage/updated" || + method === "turn/started" || + method === "turn/completed" || + method === "turn/aborted" || + method === "turn/plan/updated" || + method === "item/plan/delta" + ); + } + private readObject(value: unknown, key?: string): Record | undefined { const target = key === undefined diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index f60dc7c100..78f8c3cac0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -454,7 +454,7 @@ function runStackedAction( provider?: | "codex" | "copilot" - | "claudeCode" + | "claudeAgent" | "cursor" | "opencode" | "geminiCli" diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 64547bd0ef..1f366eacbe 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, @@ -45,15 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: - | "codex" - | "copilot" - | "claudeCode" - | "cursor" - | "opencode" - | "geminiCli" - | "amp" - | "kilo"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -67,7 +59,7 @@ function createProviderServiceHarness( cwd: string, hasSession = true, sessionCwd = cwd, - providerName: "codex" | "claudeCode" = "codex", + providerName: "codex" | "claudeAgent" = "codex", ) { const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -244,7 +236,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; - readonly providerName?: "codex" | "claudeCode"; + readonly providerName?: "codex" | "claudeAgent"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -487,10 +479,10 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); - it("captures pre-turn and completion checkpoints for claudeCode runtime events", async () => { + it("captures pre-turn and completion checkpoints for claudeAgent runtime events", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, - providerName: "claudeCode", + providerName: "claudeAgent", }); const createdAt = new Date().toISOString(); @@ -502,7 +494,7 @@ describe("CheckpointReactor", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", - providerName: "claudeCode", + providerName: "claudeAgent", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -515,7 +507,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), - provider: "claudeCode", + provider: "claudeAgent", createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-claude-1"), @@ -529,7 +521,7 @@ describe("CheckpointReactor", () => { harness.provider.emit({ type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), - provider: "claudeCode", + provider: "claudeAgent", createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-claude-1"), @@ -863,8 +855,8 @@ describe("CheckpointReactor", () => { ).toBe(false); }); - it("executes provider revert and emits thread.reverted for claudeCode sessions", async () => { - const harness = await createHarness({ providerName: "claudeCode" }); + it("executes provider revert and emits thread.reverted for claudeAgent sessions", async () => { + const harness = await createHarness({ providerName: "claudeAgent" }); const createdAt = new Date().toISOString(); await Effect.runPromise( @@ -875,7 +867,7 @@ describe("CheckpointReactor", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", - providerName: "claudeCode", + providerName: "claudeAgent", runtimeMode: "approval-required", activeTurnId: null, lastError: null, 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 6ae94105a6..d46764cc8c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -653,6 +653,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, turnId: event.payload.proposedPlan.turnId, planMarkdown: event.payload.proposedPlan.planMarkdown, + implementedAt: event.payload.proposedPlan.implementedAt, + implementationThreadId: event.payload.proposedPlan.implementationThreadId, createdAt: event.payload.proposedPlan.createdAt, updatedAt: event.payload.proposedPlan.updatedAt, }); @@ -775,6 +777,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, messageId: event.payload.messageId, + sourceProposedPlanThreadId: event.payload.sourceProposedPlan?.threadId ?? null, + sourceProposedPlanId: event.payload.sourceProposedPlan?.planId ?? null, requestedAt: event.payload.createdAt, }); return; @@ -804,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) @@ -822,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) @@ -875,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, @@ -910,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, @@ -954,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 fc7db54802..b5b73fd6e0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -26,6 +26,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; + yield* sql`DELETE FROM projection_thread_proposed_plans`; yield* sql`DELETE FROM projection_turns`; yield* sql` @@ -101,6 +102,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + 'plan-1', + 'thread-1', + 'turn-1', + '# Ship it', + '2026-02-24T00:00:05.500Z', + 'thread-2', + '2026-02-24T00:00:05.000Z', + '2026-02-24T00:00:05.500Z' + ) + `; + yield* sql` INSERT INTO projection_thread_activities ( activity_id, @@ -154,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, @@ -168,6 +194,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'turn-1', NULL, + 'thread-1', + 'plan-1', 'message-1', 'completed', '2026-02-24T00:00:08.000Z', @@ -238,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", @@ -253,7 +285,17 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:05.000Z", }, ], - proposedPlans: [], + proposedPlans: [ + { + id: "plan-1", + turnId: asTurnId("turn-1"), + planMarkdown: "# Ship it", + implementedAt: "2026-02-24T00:00:05.500Z", + implementationThreadId: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-02-24T00:00:05.000Z", + updatedAt: "2026-02-24T00:00:05.500Z", + }, + ], activities: [ { id: asEventId("activity-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5fd38a5401..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; @@ -200,6 +204,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", turn_id AS "turnId", plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -284,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 @@ -435,6 +443,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.planId, turnId: row.turnId, planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -497,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 4c530ecec9..ef4ef4ba69 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -84,9 +84,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; @@ -98,7 +102,7 @@ describe("ProviderCommandReactor", () => { input !== null && "provider" in input && (input.provider === "codex" || - input.provider === "claudeCode" || + input.provider === "claudeAgent" || input.provider === "cursor") ? input.provider : "codex"; @@ -132,7 +136,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(); @@ -404,7 +508,7 @@ describe("ProviderCommandReactor", () => { text: "hello claude", attachments: [], }, - provider: "claudeCode", + provider: "claudeAgent", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -414,7 +518,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - provider: "claudeCode", + provider: "claudeAgent", cwd: "/tmp/provider-project", model: "gpt-5-codex", runtimeMode: "approval-required", @@ -422,7 +526,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerName).toBe("claudeCode"); + expect(thread?.session?.providerName).toBe("claudeAgent"); expect(thread?.session?.threadId).toBe("thread-1"); }); @@ -509,73 +613,28 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); - it("reuses the same cursor session when requested model is unchanged", async () => { - const harness = await createHarness(); + 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-cursor-model-same-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-cursor-model-same-1"), + messageId: asMessageId("user-message-claude-effort-1"), role: "user", - text: "first", + text: "first claude turn", attachments: [], }, - provider: "cursor", - model: "composer-1.5", - 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-cursor-model-same-2"), - threadId: ThreadId.makeUnsafe("thread-1"), - message: { - messageId: asMessageId("user-message-cursor-model-same-2"), - role: "user", - text: "second", - attachments: [], - }, - provider: "cursor", - model: "composer-1.5", - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); - }); - - it("keeps cursor session/model when model change is unsupported", 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-cursor-model-change-1"), - threadId: ThreadId.makeUnsafe("thread-1"), - message: { - messageId: asMessageId("user-message-cursor-model-change-1"), - role: "user", - text: "first", - attachments: [], + provider: "claudeAgent", + model: "claude-sonnet-4-6", + modelOptions: { + claudeAgent: { + effort: "medium", + }, }, - provider: "cursor", - model: "gpt-5.3-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -588,29 +647,37 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"), + commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-cursor-model-change-2"), + messageId: asMessageId("user-message-claude-effort-2"), role: "user", - text: "second", + text: "second claude turn", attachments: [], }, - provider: "cursor", - model: "composer-1.5", + 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.stopSession.mock.calls.length).toBe(0); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), - model: "gpt-5.3-codex", + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "claudeAgent", + resumeCursor: { opaque: "resume-1" }, + modelOptions: { + claudeAgent: { + effort: "max", + }, + }, }); }); @@ -688,7 +755,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({ @@ -736,7 +803,7 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, - provider: "claudeCode", + provider: "claudeAgent", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -749,7 +816,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - provider: "claudeCode", + provider: "claudeAgent", runtimeMode: "approval-required", }); expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty("resumeCursor"); @@ -757,7 +824,7 @@ describe("ProviderCommandReactor", () => { 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("claudeCode"); + expect(thread?.session?.providerName).toBe("claudeAgent"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -1036,6 +1103,7 @@ describe("ProviderCommandReactor", () => { expect(failureActivity).toBeDefined(); expect(failureActivity?.payload).toMatchObject({ requestId: "approval-request-1", + detail: expect.stringContaining("Stale pending approval request: approval-request-1"), }); const resolvedActivity = thread?.activities.find( @@ -1048,6 +1116,117 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); + it("surfaces stale provider user-input failures without faking user-input resolution", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.respondToUserInput.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "claudeAgent", + method: "item/tool/respondToUserInput", + detail: "Unknown pending user-input request: user-input-request-1", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.makeUnsafe("cmd-user-input-requested"), + threadId: ThreadId.makeUnsafe("thread-1"), + activity: { + id: EventId.makeUnsafe("activity-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + sandbox_mode: "workspace-write", + }, + 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"), + ); + if (!thread) return false; + return thread.activities.some( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + expect(failureActivity).toBeDefined(); + expect(failureActivity?.payload).toMatchObject({ + requestId: "user-input-request-1", + detail: expect.stringContaining("Stale pending user-input request: user-input-request-1"), + }); + + const resolvedActivity = thread?.activities.find( + (activity) => + activity.kind === "user-input.resolved" && + typeof activity.payload === "object" && + activity.payload !== null && + (activity.payload as Record).requestId === "user-input-request-1", + ); + expect(resolvedActivity).toBeUndefined(); + }); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", 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 6e747b8e45..8543601e6c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -18,12 +18,15 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderServiceError } from "../../provider/Errors.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { inferProviderForModel } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -74,6 +77,42 @@ 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 sameClaudeModelOptions = ( + left: ProviderModelOptions | undefined, + right: ProviderModelOptions | undefined, +): boolean => + JSON.stringify(left?.claudeAgent ?? null) === JSON.stringify(right?.claudeAgent ?? null); + +function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { + const error = Cause.squash(cause); + if (Schema.is(ProviderAdapterRequestError)(error)) { + const detail = error.detail.toLowerCase(); + return ( + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); + } + const message = Cause.pretty(cause); + return ( + message.includes("unknown pending approval request") || + message.includes("unknown pending permission request") + ); +} + +function isUnknownPendingUserInputRequestError(cause: Cause.Cause): boolean { + const error = Cause.squash(cause); + if (Schema.is(ProviderAdapterRequestError)(error)) { + return error.detail.toLowerCase().includes("unknown pending user-input request"); + } + return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request"); +} + +function stalePendingRequestDetail( + requestKind: "approval" | "user-input", + requestId: string, +): string { + return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; +} function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } @@ -125,6 +164,8 @@ const make = Effect.gen(function* () { ), ); + const threadModelOptions = new Map(); + const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; readonly kind: @@ -275,8 +316,18 @@ 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 && + !sameClaudeModelOptions(previousModelOptions, options.modelOptions); - if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + if ( + !runtimeModeChanged && + !providerChanged && + !shouldRestartForModelChange && + !shouldRestartForModelOptionsChange + ) { return existingSessionThreadId; } @@ -295,6 +346,7 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, + shouldRestartForModelOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession({ @@ -334,15 +386,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 @@ -483,7 +538,21 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, - }); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return 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* ( @@ -561,7 +630,9 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.approval.respond.failed", summary: "Provider approval response failed", - detail: Cause.pretty(cause), + detail: isUnknownPendingApprovalRequestError(cause) + ? stalePendingRequestDetail("approval", event.payload.requestId) + : Cause.pretty(cause), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, @@ -607,7 +678,9 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.user-input.respond.failed", summary: "Provider user input response failed", - detail: Cause.pretty(cause), + detail: isUnknownPendingUserInputRequestError(cause) + ? stalePendingRequestDetail("user-input", event.payload.requestId) + : Cause.pretty(cause), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, @@ -688,13 +761,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 e4c0c67276..301e046a85 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { + OrchestrationReadModel, + ProviderKind, + ProviderRuntimeEvent, + ProviderSession, +} from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -46,15 +51,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: - | "codex" - | "copilot" - | "claudeCode" - | "cursor" - | "opencode" - | "geminiCli" - | "amp" - | "kilo"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -66,6 +63,7 @@ type LegacyProviderRuntimeEvent = { function createProviderServiceHarness() { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeSessions: ProviderSession[] = []; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -75,12 +73,21 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), - listSessions: () => Effect.succeed([]), + listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed(getProviderCapabilities("codex")), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; + const setSession = (session: ProviderSession): void => { + const existingIndex = runtimeSessions.findIndex((entry) => entry.threadId === session.threadId); + if (existingIndex >= 0) { + runtimeSessions[existingIndex] = session; + return; + } + runtimeSessions.push(session); + }; + const emit = (event: LegacyProviderRuntimeEvent): void => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); }; @@ -88,6 +95,7 @@ function createProviderServiceHarness() { return { service, emit, + setSession, }; } @@ -95,11 +103,12 @@ async function waitForThread( engine: OrchestrationEngineShape, predicate: (thread: ProviderRuntimeTestThread) => boolean, timeoutMs = 2000, + threadId: ThreadId = asThreadId("thread-1"), ) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find((entry) => entry.id === threadId); if (thread && predicate(thread)) { return thread; } @@ -159,6 +168,7 @@ describe("ProviderRuntimeIngestion", () => { ); const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), @@ -214,10 +224,19 @@ describe("ProviderRuntimeIngestion", () => { createdAt, }), ); + provider.setSession({ + provider: "codex", + status: "ready", + runtimeMode: "approval-required", + threadId: ThreadId.makeUnsafe("thread-1"), + createdAt, + updatedAt: createdAt, + }); return { engine, emit: provider.emit, + setProviderSession: provider.setSession, drain, }; } @@ -423,7 +442,7 @@ describe("ProviderRuntimeIngestion", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", - providerName: "claudeCode", + providerName: "claudeAgent", runtimeMode: "approval-required", activeTurnId: null, updatedAt: seededAt, @@ -436,7 +455,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-claude-placeholder"), - provider: "claudeCode", + provider: "claudeAgent", createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), @@ -452,7 +471,7 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-claude-placeholder"), - provider: "claudeCode", + provider: "claudeAgent", createdAt: new Date().toISOString(), threadId: asThreadId("thread-1"), turnId: asTurnId("turn-claude-placeholder"), @@ -713,6 +732,496 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("marks the source proposed plan implemented only after the target turn starts", async () => { + const harness = await createHarness(); + const sourceThreadId = asThreadId("thread-plan"); + const targetThreadId = asThreadId("thread-implement"); + const sourceTurnId = asTurnId("turn-plan-source"); + const targetTurnId = asTurnId("turn-plan-implement"); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source"), + threadId: sourceThreadId, + projectId: asProjectId("project-1"), + title: "Plan Source", + model: "gpt-5-codex", + interactionMode: "plan", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-plan-source"), + threadId: sourceThreadId, + session: { + threadId: sourceThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target"), + threadId: targetThreadId, + projectId: asProjectId("project-1"), + title: "Plan Target", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-plan-target"), + threadId: targetThreadId, + session: { + threadId: targetThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }), + ); + harness.setProviderSession({ + provider: "codex", + status: "ready", + runtimeMode: "approval-required", + threadId: targetThreadId, + createdAt, + updatedAt: createdAt, + activeTurnId: targetTurnId, + }); + + harness.emit({ + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-source-completed"), + provider: "codex", + createdAt, + threadId: sourceThreadId, + turnId: sourceTurnId, + payload: { + planMarkdown: "# Source plan", + }, + }); + + const sourceThreadWithPlan = await waitForThread( + harness.engine, + (thread) => + thread.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" && + proposedPlan.implementedAt === null, + ), + 2_000, + sourceThreadId, + ); + const sourcePlan = sourceThreadWithPlan.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => + entry.id === "plan:thread-plan:turn:turn-plan-source", + ); + expect(sourcePlan).toBeDefined(); + if (!sourcePlan) { + throw new Error("Expected source plan to exist."); + } + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target"), + threadId: targetThreadId, + message: { + messageId: asMessageId("msg-plan-target"), + role: "user", + text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan", + attachments: [], + }, + sourceProposedPlan: { + threadId: sourceThreadId, + planId: sourcePlan.id, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); + + const sourceThreadBeforeStart = await waitForThread( + harness.engine, + (thread) => + thread.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === sourcePlan.id && proposedPlan.implementedAt === null, + ), + 2_000, + sourceThreadId, + ); + expect( + sourceThreadBeforeStart.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + implementedAt: null, + implementationThreadId: null, + }); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-plan-target-started"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: targetThreadId, + turnId: targetTurnId, + }); + + const sourceThreadAfterStart = await waitForThread( + harness.engine, + (thread) => + thread.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === sourcePlan.id && + proposedPlan.implementedAt !== null && + proposedPlan.implementationThreadId === targetThreadId, + ), + 2_000, + sourceThreadId, + ); + expect( + sourceThreadAfterStart.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + implementationThreadId: "thread-implement", + }); + }); + + it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { + const harness = await createHarness(); + const sourceThreadId = asThreadId("thread-plan"); + const targetThreadId = asThreadId("thread-1"); + const sourceTurnId = asTurnId("turn-plan-source"); + const activeTurnId = asTurnId("turn-already-running"); + const staleTurnId = asTurnId("turn-stale-start"); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-guarded"), + threadId: sourceThreadId, + projectId: asProjectId("project-1"), + title: "Plan Source", + model: "gpt-5-codex", + interactionMode: "plan", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-guarded"), + threadId: sourceThreadId, + session: { + threadId: sourceThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }), + ); + harness.setProviderSession({ + provider: "codex", + status: "running", + runtimeMode: "approval-required", + threadId: targetThreadId, + createdAt, + updatedAt: createdAt, + activeTurnId, + }); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-already-running"), + provider: "codex", + createdAt, + threadId: targetThreadId, + turnId: activeTurnId, + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId, + 2_000, + targetThreadId, + ); + + harness.emit({ + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-source-completed-guarded"), + provider: "codex", + createdAt, + threadId: sourceThreadId, + turnId: sourceTurnId, + payload: { + planMarkdown: "# Source plan", + }, + }); + + const sourceThreadWithPlan = await waitForThread( + harness.engine, + (thread) => + thread.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" && + proposedPlan.implementedAt === null, + ), + 2_000, + sourceThreadId, + ); + const sourcePlan = sourceThreadWithPlan.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => + entry.id === "plan:thread-plan:turn:turn-plan-source", + ); + expect(sourcePlan).toBeDefined(); + if (!sourcePlan) { + throw new Error("Expected source plan to exist."); + } + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-guarded"), + threadId: targetThreadId, + message: { + messageId: asMessageId("msg-plan-target-guarded"), + role: "user", + text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan", + attachments: [], + }, + sourceProposedPlan: { + threadId: sourceThreadId, + planId: sourcePlan.id, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-stale-plan-implementation"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: targetThreadId, + turnId: staleTurnId, + }); + + await harness.drain(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const sourceThreadAfterRejectedStart = readModel.threads.find( + (entry) => entry.id === sourceThreadId, + ); + expect( + sourceThreadAfterRejectedStart?.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + implementedAt: null, + implementationThreadId: null, + }); + + const targetThreadAfterRejectedStart = readModel.threads.find( + (entry) => entry.id === targetThreadId, + ); + expect(targetThreadAfterRejectedStart?.session?.status).toBe("running"); + expect(targetThreadAfterRejectedStart?.session?.activeTurnId).toBe(activeTurnId); + }); + + it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { + const harness = await createHarness(); + const sourceThreadId = asThreadId("thread-plan"); + const targetThreadId = asThreadId("thread-implement"); + const sourceTurnId = asTurnId("turn-plan-source"); + const expectedTurnId = asTurnId("turn-plan-implement"); + const replayedTurnId = asTurnId("turn-replayed"); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-source-unrelated"), + threadId: sourceThreadId, + projectId: asProjectId("project-1"), + title: "Plan Source", + model: "gpt-5-codex", + interactionMode: "plan", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-plan-source-unrelated"), + threadId: sourceThreadId, + session: { + threadId: sourceThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-plan-target-unrelated"), + threadId: targetThreadId, + projectId: asProjectId("project-1"), + title: "Plan Target", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-plan-target-unrelated"), + threadId: targetThreadId, + session: { + threadId: targetThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }), + ); + + harness.emit({ + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-source-completed-unrelated"), + provider: "codex", + createdAt, + threadId: sourceThreadId, + turnId: sourceTurnId, + payload: { + planMarkdown: "# Source plan", + }, + }); + + const sourceThreadWithPlan = await waitForThread( + harness.engine, + (thread) => + thread.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-plan:turn:turn-plan-source" && + proposedPlan.implementedAt === null, + ), + 2_000, + sourceThreadId, + ); + const sourcePlan = sourceThreadWithPlan.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => + entry.id === "plan:thread-plan:turn:turn-plan-source", + ); + expect(sourcePlan).toBeDefined(); + if (!sourcePlan) { + throw new Error("Expected source plan to exist."); + } + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-plan-target-unrelated"), + threadId: targetThreadId, + message: { + messageId: asMessageId("msg-plan-target-unrelated"), + role: "user", + text: "PLEASE IMPLEMENT THIS PLAN:\n# Source plan", + attachments: [], + }, + sourceProposedPlan: { + threadId: sourceThreadId, + planId: sourcePlan.id, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: new Date().toISOString(), + }), + ); + + harness.setProviderSession({ + provider: "codex", + status: "running", + runtimeMode: "approval-required", + threadId: targetThreadId, + createdAt, + updatedAt: createdAt, + activeTurnId: expectedTurnId, + }); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-unrelated-plan-implementation"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: targetThreadId, + turnId: replayedTurnId, + }); + + await harness.drain(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const sourceThreadAfterUnrelatedStart = readModel.threads.find( + (entry) => entry.id === sourceThreadId, + ); + expect( + sourceThreadAfterUnrelatedStart?.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + implementedAt: null, + implementationThreadId: null, + }); + }); + it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1690,6 +2199,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.", }, }); @@ -1752,8 +2262,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 8851cc3dfd..78570cef30 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -4,6 +4,7 @@ import { CommandId, MessageId, type OrchestrationEvent, + type OrchestrationProposedPlanId, CheckpointRef, isToolLifecycleItemType, ThreadId, @@ -15,6 +16,8 @@ import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effe import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { isGitRepository } from "../../git/isRepo.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -403,7 +406,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 } : {}), }, @@ -513,6 +517,7 @@ function runtimeEventToActivities( const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const projectionTurnRepository = yield* ProjectionTurnRepository; const assistantDeliveryModeRef = yield* Ref.make( DEFAULT_ASSISTANT_DELIVERY_MODE, @@ -921,6 +926,8 @@ const make = Effect.gen(function* () { threadProposedPlans: ReadonlyArray<{ id: string; createdAt: string; + implementedAt: string | null; + implementationThreadId: ThreadId | null; }>; planId: string; turnId?: TurnId; @@ -943,6 +950,8 @@ const make = Effect.gen(function* () { id: input.planId, turnId: input.turnId ?? null, planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, createdAt: existingPlan?.createdAt ?? input.createdAt, updatedAt: input.updatedAt, }, @@ -956,6 +965,8 @@ const make = Effect.gen(function* () { threadProposedPlans: ReadonlyArray<{ id: string; createdAt: string; + implementedAt: string | null; + implementationThreadId: ThreadId | null; }>; planId: string; turnId?: TurnId; @@ -1028,6 +1039,79 @@ const make = Effect.gen(function* () { // turn.completed) still get turn-level usage in the completion summary. const pendingTokenUsageByThread = new Map>(); + const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fnUntraced(function* ( + threadId: ThreadId, + ) { + const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId, + }); + if (Option.isNone(pendingTurnStart)) { + return null; + } + + const sourceThreadId = pendingTurnStart.value.sourceProposedPlanThreadId; + const sourcePlanId = pendingTurnStart.value.sourceProposedPlanId; + if (sourceThreadId === null || sourcePlanId === null) { + return null; + } + + return { + sourceThreadId, + sourcePlanId, + } as const; + }); + + const getExpectedProviderTurnIdForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const sessions = yield* providerService.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + return session?.activeTurnId; + }); + + const getSourceProposedPlanReferenceForAcceptedTurnStart = Effect.fnUntraced(function* ( + threadId: ThreadId, + eventTurnId: TurnId | undefined, + ) { + if (eventTurnId === undefined) { + return null; + } + + const expectedTurnId = yield* getExpectedProviderTurnIdForThread(threadId); + if (!sameId(expectedTurnId, eventTurnId)) { + return null; + } + + return yield* getSourceProposedPlanReferenceForPendingTurnStart(threadId); + }); + + const markSourceProposedPlanImplemented = Effect.fnUntraced(function* ( + sourceThreadId: ThreadId, + sourcePlanId: OrchestrationProposedPlanId, + implementationThreadId: ThreadId, + implementedAt: string, + ) { + const readModel = yield* orchestrationEngine.getReadModel(); + const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId); + const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); + if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: CommandId.makeUnsafe( + `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`, + ), + threadId: sourceThread.id, + proposedPlan: { + ...sourcePlan, + implementedAt, + implementationThreadId, + updatedAt: implementedAt, + }, + createdAt: implementedAt, + }); + }); + const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { // Accumulate token usage events per thread @@ -1104,6 +1188,10 @@ const make = Effect.gen(function* () { return true; } })(); + const acceptedTurnStartedSourcePlan = + event.type === "turn.started" && shouldApplyThreadLifecycle + ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) + : null; if ( event.type === "session.started" || @@ -1172,6 +1260,26 @@ const make = Effect.gen(function* () { pendingTokenUsageByThread.delete(event.threadId); } + if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { + yield* markSourceProposedPlanImplemented( + acceptedTurnStartedSourcePlan.sourceThreadId, + acceptedTurnStartedSourcePlan.sourcePlanId, + thread.id, + now, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion failed to mark source proposed plan", + { + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }, + ), + ), + ); + } + yield* orchestrationEngine.dispatch({ type: "thread.session.set", commandId: providerCommandId(event, "thread-session-set"), @@ -1520,4 +1628,7 @@ const make = Effect.gen(function* () { } satisfies ProviderRuntimeIngestionShape; }); -export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make); +export const ProviderRuntimeIngestionLive = Layer.effect( + ProviderRuntimeIngestionService, + make, +).pipe(Layer.provide(ProjectionTurnRepositoryLive)); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 744ca9ef43..e68103ab6d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -262,11 +262,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { - yield* requireThread({ + const targetThread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const sourceProposedPlan = command.sourceProposedPlan; + const sourceThread = sourceProposedPlan + ? yield* requireThread({ + readModel, + command, + threadId: sourceProposedPlan.threadId, + }) + : null; + const sourcePlan = + sourceProposedPlan && sourceThread + ? sourceThread.proposedPlans.find((entry) => entry.id === sourceProposedPlan.planId) + : null; + if (sourceProposedPlan && !sourcePlan) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${sourceProposedPlan.planId}' does not exist on thread '${sourceProposedPlan.threadId}'.`, + }); + } + if (sourceThread && sourceThread.projectId !== targetThread.projectId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`, + }); + } const userMessageEvent: Omit = { ...withEventBase({ aggregateKind: "thread", @@ -306,12 +330,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { providerOptions: command.providerOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - runtimeMode: - readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? - command.runtimeMode, - interactionMode: - readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ?? - command.interactionMode, + runtimeMode: targetThread.runtimeMode, + interactionMode: targetThread.interactionMode, + ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), createdAt: command.createdAt, }, }; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 3d103592f9..ccd322feb2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -22,6 +22,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { thread_id, turn_id, plan_markdown, + implemented_at, + implementation_thread_id, created_at, updated_at ) @@ -30,6 +32,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ${row.threadId}, ${row.turnId}, ${row.planMarkdown}, + ${row.implementedAt}, + ${row.implementationThreadId}, ${row.createdAt}, ${row.updatedAt} ) @@ -38,6 +42,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { thread_id = excluded.thread_id, turn_id = excluded.turn_id, plan_markdown = excluded.plan_markdown, + implemented_at = excluded.implemented_at, + implementation_thread_id = excluded.implementation_thread_id, created_at = excluded.created_at, updated_at = excluded.updated_at `, @@ -52,6 +58,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { thread_id AS "threadId", turn_id AS "turnId", plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index 8330661e30..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, @@ -106,6 +112,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, @@ -120,6 +128,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ${row.threadId}, NULL, ${row.messageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, NULL, 'pending', ${row.requestedAt}, @@ -141,6 +151,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", pending_message_id AS "messageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", requested_at AS "requestedAt" FROM projection_turns WHERE thread_id = ${threadId} @@ -162,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", @@ -193,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/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd8..ea1821014a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,8 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; +import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; import { Effect } from "effect"; /** @@ -51,6 +53,8 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_ProjectionThreadProposedPlanImplementation": Migration0014, + "15_ProjectionTurnsSourceProposedPlan": Migration0015, }); /** diff --git a/apps/server/src/persistence/Migrations/014_ProjectionThreadProposedPlanImplementation.ts b/apps/server/src/persistence/Migrations/014_ProjectionThreadProposedPlanImplementation.ts new file mode 100644 index 0000000000..c7a82bfb33 --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_ProjectionThreadProposedPlanImplementation.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN implemented_at TEXT + `; + + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN implementation_thread_id TEXT + `; +}); diff --git a/apps/server/src/persistence/Migrations/015_ProjectionTurnsSourceProposedPlan.ts b/apps/server/src/persistence/Migrations/015_ProjectionTurnsSourceProposedPlan.ts new file mode 100644 index 0000000000..57a2661879 --- /dev/null +++ b/apps/server/src/persistence/Migrations/015_ProjectionTurnsSourceProposedPlan.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_turns + ADD COLUMN source_proposed_plan_thread_id TEXT + `; + + yield* sql` + ALTER TABLE projection_turns + ADD COLUMN source_proposed_plan_id TEXT + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index ee662d52be..d141a11bb9 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -15,6 +15,8 @@ export const ProjectionThreadProposedPlan = Schema.Struct({ threadId: ThreadId, turnId: Schema.NullOr(TurnId), planMarkdown: TrimmedNonEmptyString, + implementedAt: Schema.NullOr(IsoDateTime), + implementationThreadId: Schema.NullOr(ThreadId), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index 1c791342ff..95dab450bf 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -11,6 +11,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + OrchestrationProposedPlanId, OrchestrationCheckpointFile, OrchestrationCheckpointStatus, ThreadId, @@ -34,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, @@ -50,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, @@ -65,6 +70,8 @@ export type ProjectionTurnById = typeof ProjectionTurnById.Type; export const ProjectionPendingTurnStart = Schema.Struct({ threadId: ThreadId, messageId: MessageId, + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), requestedAt: IsoDateTime, }); export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; 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..a3f67651d8 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -0,0 +1,2764 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + ApprovalRequestId, + ProviderItemId, + ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Layer, Random, Stream } from "effect"; + +import { attachmentRelativePath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +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 waiters: Array<{ + readonly resolve: (value: IteratorResult) => void; + readonly reject: (reason: unknown) => void; + }> = []; + private done = false; + private failure: unknown | undefined; + + 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 waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ done: false, value: message }); + return; + } + this.queue.push(message); + } + + fail(cause: unknown): void { + if (this.done) { + return; + } + this.done = true; + this.failure = cause; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(cause); + } + } + + finish(): void { + if (this.done) { + return; + } + this.done = true; + this.failure = undefined; + for (const waiter of this.waiters.splice(0)) { + waiter.resolve({ 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.failure !== undefined) { + const failure = this.failure; + this.failure = undefined; + return Promise.reject(failure); + } + if (this.done) { + return Promise.resolve({ + done: true, + value: undefined, + }); + } + return new Promise((resolve, reject) => { + this.waiters.push({ + resolve, + reject, + }); + }); + }, + }; + } +} + +function makeHarness(config?: { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; + readonly cwd?: string; + readonly stateDir?: string; +}) { + 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).pipe( + Layer.provideMerge( + ServerConfig.layerTest( + config?.cwd ?? "/tmp/claude-adapter-test", + config?.stateDir ?? "/tmp", + ), + ), + Layer.provideMerge(NodeServices.layer), + ), + 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 as { content?: Array<{ type?: string; text?: string }> }) + ?.content?.[0]; + if (!content || content.type !== "text") { + return undefined; + } + return content.text; +} + +async function readFirstPromptMessage( + 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; + } + return next.value; +} + +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("embeds image attachments in Claude user messages", () => { + const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); + const harness = makeHarness({ + cwd: "/tmp/project-claude-attachments", + stateDir, + }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => + rmSync(stateDir, { + recursive: true, + force: true, + }), + ), + ); + + const adapter = yield* ClaudeAdapter; + + const attachment = { + type: "image" as const, + id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc", + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment)); + mkdirSync(path.dirname(attachmentPath), { recursive: true }); + writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "What's in this image?", + attachments: [attachment], + }); + + const createInput = harness.getLastCreateQueryInput(); + const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput)); + assert.isDefined(promptMessage); + assert.deepEqual((promptMessage?.message as Record)?.content, [ + { + type: "text", + text: "What's in this image?", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + }, + ]); + }).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("closes the session when the Claude stream aborts after a turn starts", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + + const runtimeEventsFiber = Effect.runFork( + Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ), + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.fail(new Error("All fibers interrupted without error")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "turn.completed", + "session.exited", + ], + ); + + const turnCompleted = runtimeEvents[4]; + 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, "Claude runtime interrupted."); + } + + const sessionExited = runtimeEvents[5]; + assert.equal(sessionExited?.type, "session.exited"); + + assert.equal(yield* adapter.hasSession(THREAD_ID), false); + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 0); + assert.equal(harness.query.closeCalls, 1); + }).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("creates a fresh assistant message when Claude reuses a text block index", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).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-reused-text-index", + uuid: "stream-reused-start-1", + 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-reused-text-index", + uuid: "stream-reused-delta-1", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "First", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-reused-text-index", + uuid: "stream-reused-stop-1", + 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-reused-text-index", + uuid: "stream-reused-start-2", + 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-reused-text-index", + uuid: "stream-reused-delta-2", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Second", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-reused-text-index", + uuid: "stream-reused-stop-2", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 0, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-reused-text-index", + uuid: "result-reused-text-index", + } 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", + "content.delta", + "item.completed", + ], + ); + + const assistantDeltas = runtimeEvents.filter( + (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ); + assert.equal(assistantDeltas.length, 2); + if (assistantDeltas.length !== 2) { + return; + } + const [firstAssistantDelta, secondAssistantDelta] = assistantDeltas; + assert.equal(firstAssistantDelta?.type, "content.delta"); + assert.equal(secondAssistantDelta?.type, "content.delta"); + if ( + firstAssistantDelta?.type !== "content.delta" || + secondAssistantDelta?.type !== "content.delta" + ) { + return; + } + assert.equal(firstAssistantDelta.payload.delta, "First"); + assert.equal(secondAssistantDelta.payload.delta, "Second"); + assert.notEqual(firstAssistantDelta.itemId, secondAssistantDelta.itemId); + + const assistantCompletions = runtimeEvents.filter( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + assert.equal(assistantCompletions.length, 2); + assert.equal(String(assistantCompletions[0]?.itemId), String(firstAssistantDelta.itemId)); + assert.equal(String(assistantCompletions[1]?.itemId), String(secondAssistantDelta.itemId)); + assert.notEqual( + String(assistantCompletions[0]?.itemId), + String(assistantCompletions[1]?.itemId), + ); + }).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 Claude resume ids without pinning a stale assistant checkpoint", () => { + 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.sessionId, undefined); + assert.equal(createInput?.options.resumeSessionAt, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("uses an app-generated Claude session id for fresh sessions", () => { + 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 createInput = harness.getLastCreateQueryInput(); + const sessionResumeCursor = session.resumeCursor as { + threadId?: string; + resume?: string; + turnCount?: number; + }; + assert.equal(sessionResumeCursor.threadId, THREAD_ID); + assert.equal(typeof sessionResumeCursor.resume, "string"); + assert.equal(sessionResumeCursor.turnCount, 0); + assert.match( + sessionResumeCursor.resume ?? "", + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + assert.equal(createInput?.options.resume, undefined); + assert.equal(createInput?.options.sessionId, sessionResumeCursor.resume); + }).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("denies AskUserQuestion when the waiting turn is aborted", () => { + 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 controller = new AbortController(); + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Proceed" }], + multiSelect: false, + }, + ], + }, + { + signal: controller.signal, + toolUseID: "tool-ask-abort", + }, + ); + + 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; + } + assert.equal(requestedEvent.value.threadId, session.threadId); + + controller.abort(); + + const resolvedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolvedEvent._tag, "Some"); + if (resolvedEvent._tag !== "Some" || resolvedEvent.value.type !== "user-input.resolved") { + assert.fail("Expected user-input.resolved event"); + return; + } + assert.deepEqual(resolvedEvent.value.payload.answers, {}); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.deepEqual(permissionResult, { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + }).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/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts similarity index 50% rename from apps/server/src/provider/Layers/ClaudeCodeAdapter.ts rename to apps/server/src/provider/Layers/ClaudeAdapter.ts index 5f1a328b51..0b3a20aebe 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1,17 +1,13 @@ /** - * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * 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 ClaudeCodeAdapterLive + * @module ClaudeAdapterLive */ -import { createRequire } from "node:module"; -import * as Path from "node:path"; - import { type CanUseTool, - type EffortLevel, query, type Options as ClaudeQueryOptions, type PermissionMode, @@ -32,15 +28,41 @@ import { type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, type ProviderSession, + type ProviderUserInputAnswers, + type RuntimeContentStreamKind, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, ThreadId, TurnId, + type UserInputQuestion, } from "@t3tools/contracts"; -import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; - -import type { ProviderUsageQuota, ProviderUsageResult } from "@t3tools/contracts"; +import { + applyClaudePromptEffortPrefix, + getEffectiveClaudeCodeEffort, + getReasoningEffortOptions, + resolveReasoningEffortForProvider, + supportsClaudeFastMode, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, +} from "@t3tools/shared/model"; +import { + Cause, + DateTime, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Queue, + Random, + Ref, + Stream, +} from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -49,124 +71,30 @@ import { ProviderAdapterValidationError, type ProviderAdapterError, } from "../Errors.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { getProviderCapabilities } from "../Services/ProviderAdapter.ts"; -import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { toMessage } from "../toMessage.ts"; - -import { createLogger } from "../../logger"; - -const PROVIDER = "claudeCode" as const; -const logger = createLogger(PROVIDER); - -/** - * Environment variables that must be stripped before spawning the Claude Code - * subprocess. These are set by the Electron desktop shell and leak into the - * server's `process.env` when T3 Code runs in desktop mode. Passing them - * through to the Claude CLI can cause it to exit immediately with code 1. - */ -const SPAWN_ENV_BLOCKLIST = new Set([ - "ELECTRON_RUN_AS_NODE", - "ELECTRON_RENDERER_PORT", - "CLAUDECODE", -]); - -const DESKTOP_DIAGNOSTIC_ENV_PREFIXES = ["CLAUDE", "ELECTRON", "CODEX", "T3CODE"] as const; - -function sanitizedEnv(): Record { - const env = { ...process.env }; - for (const key of SPAWN_ENV_BLOCKLIST) { - delete env[key]; - } - return env; -} - -const require = createRequire(import.meta.url); -let resolvedClaudeSdkCliPath: string | undefined; - -function defaultClaudeSdkCliPath(): string { - if (resolvedClaudeSdkCliPath) { - return resolvedClaudeSdkCliPath; - } - - const sdkEntry = require.resolve("@anthropic-ai/claude-agent-sdk"); - resolvedClaudeSdkCliPath = Path.join(Path.dirname(sdkEntry), "cli.js"); - return resolvedClaudeSdkCliPath; -} - -/** - * Loose accessor type for SDKMessage dynamic properties that arrive via - * the SDK's index signature. Using this instead of `any` keeps the - * declaration file strict (`[key: string]: unknown`) while giving - * adapter code ergonomic access to SDK-emitted fields. - */ -type SDKMessageLoose = SDKMessage & Record; // oxlint-ignore-next-line -- intentional `any` for SDK index access - -// ── Module-level usage tracking ────────────────────────────────────── - -interface ClaudeCodeUsageAccumulator { - lastRateLimits: Record | null; -} -// Intentionally module-level: aggregates usage across all Claude Code sessions -// for the global usage display shown in the UI sidebar. -let _claudeUsageAccumulator: ClaudeCodeUsageAccumulator = { - lastRateLimits: null, -}; - -function storeClaudeRateLimits(message: Record): void { - _claudeUsageAccumulator.lastRateLimits = message; -} - -function rateLimitBucket( - rl: Record, - prefix: string, - label: string, -): ProviderUsageQuota | undefined { - const limit = - typeof rl[`${prefix}_limit`] === "number" ? (rl[`${prefix}_limit`] as number) : undefined; - const remaining = - typeof rl[`${prefix}_remaining`] === "number" - ? (rl[`${prefix}_remaining`] as number) - : undefined; - const reset = - typeof rl[`${prefix}_reset`] === "string" ? (rl[`${prefix}_reset`] as string) : undefined; - if (limit === undefined || remaining === undefined) return undefined; - const used = limit - remaining; - return { - plan: label, - used, - limit, - percentUsed: limit > 0 ? Math.round((used / limit) * 100) : 0, - ...(reset ? { resetDate: reset } : {}), - }; -} - -export function fetchClaudeCodeUsage(): ProviderUsageResult { - const acc = _claudeUsageAccumulator; - const rl = acc.lastRateLimits; - - // Build quotas from rate limit event if available - const quotas: ProviderUsageQuota[] = []; - if (rl) { - // Request-based limits - const reqQuota = rateLimitBucket(rl, "requests", "Requests"); - if (reqQuota) quotas.push(reqQuota); - // Input-token limits - const inputQuota = rateLimitBucket(rl, "input_tokens", "Input tokens"); - if (inputQuota) quotas.push(inputQuota); - // Output-token limits - const outputQuota = rateLimitBucket(rl, "output_tokens", "Output tokens"); - if (outputQuota) quotas.push(outputQuota); - // Generic token limits (some API tiers) - const tokensQuota = rateLimitBucket(rl, "tokens", "Tokens"); - if (tokensQuota) quotas.push(tokensQuota); - } - - return { - provider: PROVIDER, - ...(quotas.length > 0 ? { quota: quotas[0], quotas } : {}), +const PROVIDER = "claudeAgent" as const; +type ClaudeTextStreamKind = Extract; +type ClaudeToolResultStreamKind = Extract< + RuntimeContentStreamKind, + "command_output" | "file_change_output" +>; + +/** Loosely typed stream event from the Claude SDK. */ +interface ClaudeStreamEvent { + readonly type?: string; + readonly index?: number; + readonly content_block?: Record; + readonly delta?: { + readonly type?: string; + readonly text?: string; + readonly thinking?: string; + readonly partial_json?: string; + readonly [key: string]: unknown; }; + readonly [key: string]: unknown; } type PromptQueueItem = @@ -187,12 +115,21 @@ interface ClaudeResumeState { interface ClaudeTurnState { readonly turnId: TurnId; - readonly assistantItemId: string; readonly startedAt: string; readonly items: Array; - readonly messageCompleted: boolean; - readonly emittedTextDelta: boolean; - readonly fallbackAssistantText: string; + readonly assistantTextBlocks: Map; + readonly assistantTextBlockOrder: Array; + readonly capturedProposedPlanKeys: Set; + nextSyntheticAssistantBlockIndex: number; +} + +interface AssistantTextBlockState { + readonly itemId: string; + readonly blockIndex: number; + emittedTextDelta: boolean; + fallbackText: string; + streamClosed: boolean; + completionEmitted: boolean; } interface PendingApproval { @@ -202,28 +139,38 @@ interface PendingApproval { 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; + streamFiber: Fiber.Fiber | undefined; 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; - lastTurnId: TurnId | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; stopped: boolean; @@ -237,7 +184,7 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly close: () => void; } -export interface ClaudeCodeAdapterLiveOptions { +export interface ClaudeAdapterLiveOptions { readonly createQuery?: (input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; @@ -254,41 +201,83 @@ function isSyntheticClaudeThreadId(value: string): boolean { return value.startsWith("claude-thread-"); } -function toStringOrUndefined(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toError(cause: unknown, fallback: string): Error { + return cause instanceof Error ? cause : new Error(toMessage(cause, fallback)); } -function toUnknownRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" - ? (value as Record) - : undefined; +function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray { + const errors = Cause.prettyErrors(cause) + .map((error) => error.message.trim()) + .filter((message) => message.length > 0); + if (errors.length > 0) { + return errors; + } + + const squashed = toMessage(Cause.squash(cause), "").trim(); + return squashed.length > 0 ? [squashed] : []; +} + +function isClaudeInterruptedMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes("all fibers interrupted without error") || + normalized.includes("request was aborted") || + normalized.includes("interrupted by user") + ); +} + +function isClaudeInterruptedCause(cause: Cause.Cause): boolean { + return ( + Cause.hasInterruptsOnly(cause) || + normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) + ); } -function isDesktopRuntime(): boolean { - return process.env.T3CODE_MODE === "desktop"; +function messageFromClaudeStreamCause(cause: Cause.Cause, fallback: string): string { + return normalizeClaudeStreamMessages(cause)[0] ?? fallback; } -function diagnosticEnvKeys( - env: Readonly>, -): ReadonlyArray { - return Object.keys(env) - .filter((key) => DESKTOP_DIAGNOSTIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) - .toSorted(); +function interruptionMessageFromClaudeCause(cause: Cause.Cause): string { + const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted."); + return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message; } -function logDesktopClaudeDiagnostic(message: string, data?: Record): void { - if (!isDesktopRuntime()) return; - if (data) { - logger.warn(`[desktop] ${message}`, data); - return; +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; } - logger.warn(`[desktop] ${message}`); + + 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); } @@ -346,6 +335,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef 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") || @@ -368,22 +368,37 @@ function classifyToolItemType(toolName: string): CanonicalItemType { 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 classifyRequestType(toolName: string): CanonicalRequestType { +function isReadOnlyToolName(toolName: string): boolean { const normalized = toolName.toLowerCase(); - if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + 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"; } - switch (classifyToolItemType(toolName)) { - case "command_execution": - return "command_execution_approval"; - case "file_change": - return "file_change_approval"; - default: - return "dynamic_tool_call"; - } + 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 { @@ -408,6 +423,12 @@ function titleForTool(itemType: CanonicalItemType): string { 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: @@ -415,59 +436,118 @@ function titleForTool(itemType: CanonicalItemType): string { } } -function buildUserMessage( - input: ProviderSendTurnInput, -): Effect.Effect { - const fragments: string[] = []; - - if (input.input && input.input.trim().length > 0) { - fragments.push(input.input.trim()); - } - - const attachments = input.attachments ?? []; - const unsupportedAttachments = attachments.filter((a) => a.type !== "image"); - - if (unsupportedAttachments.length > 0) { - const types = [...new Set(unsupportedAttachments.map((a) => a.type))].join(", "); - return Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: `Unsupported attachment type(s): ${types}. Claude Code only supports image attachments as descriptive text.`, - }), - ); - } - - for (const attachment of attachments) { - if (attachment.type === "image") { - fragments.push( - `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, - ); - } - } - - const text = fragments.join("\n\n"); +const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); - if (text.length === 0) { - return Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: - "Cannot send an empty turn: no text input and all attachments were reduced to empty content.", - }), - ); - } +function buildPromptText(input: ProviderSendTurnInput): string { + 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; + return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); +} - return Effect.succeed({ +function buildUserMessage(input: { + readonly sdkContent: Array>; +}): SDKUserMessage { + return { type: "user", session_id: "", parent_tool_use_id: null, message: { role: "user", - content: [{ type: "text", text }], + content: input.sdkContent, + }, + } as SDKUserMessage; +} + +function buildClaudeImageContentBlock(input: { + readonly mimeType: string; + readonly bytes: Uint8Array; +}): Record { + return { + type: "image", + source: { + type: "base64", + media_type: input.mimeType, + data: Buffer.from(input.bytes).toString("base64"), }, - } as SDKUserMessage); + }; +} + +function buildUserMessageEffect( + input: ProviderSendTurnInput, + dependencies: { + readonly fileSystem: FileSystem.FileSystem; + readonly stateDir: string; + }, +): Effect.Effect { + return Effect.gen(function* () { + const text = buildPromptText(input); + const sdkContent: Array> = []; + + if (text.length > 0) { + sdkContent.push({ type: "text", text }); + } + + for (const attachment of input.attachments ?? []) { + if (attachment.type !== "image") { + continue; + } + + if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`, + }); + } + + const attachmentPath = resolveAttachmentPath({ + stateDir: dependencies.stateDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + + const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + + sdkContent.push( + buildClaudeImageContentBlock({ + mimeType: attachment.mimeType, + bytes, + }), + ); + } + + return buildUserMessage({ sdkContent }); + }); } function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { @@ -475,11 +555,8 @@ function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStat return "completed"; } - const errors = (result.errors ?? []) - .map((error) => String(error)) - .join(" ") - .toLowerCase(); - if (errors.includes("interrupt")) { + const errors = resultErrorsText(result); + if (isInterruptedResult(result)) { return "interrupted"; } if (errors.includes("cancel")) { @@ -488,24 +565,32 @@ function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStat return "failed"; } -function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { +function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind { return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; } -function providerThreadRef( - context: ClaudeSessionContext, -): { readonly providerThreadId: string } | {} { - return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +function nativeProviderRefs( + _context: ClaudeSessionContext, + options?: { + readonly providerItemId?: string | undefined; + }, +): NonNullable { + if (options?.providerItemId) { + return { + providerItemId: ProviderItemId.makeUnsafe(options.providerItemId), + }; + } + return {}; } -function extractAssistantText(message: SDKMessage): string { +function extractAssistantTextBlocks(message: SDKMessage): Array { if (message.type !== "assistant") { - return ""; + return []; } const content = (message.message as { content?: unknown } | undefined)?.content; if (!Array.isArray(content)) { - return ""; + return []; } const fragments: string[] = []; @@ -523,7 +608,141 @@ function extractAssistantText(message: SDKMessage): string { } } - return fragments.join(""); + 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( @@ -577,7 +796,7 @@ function sdkMessageSubtype(value: unknown): string | undefined { return typeof record.subtype === "string" ? record.subtype : undefined; } -function sdkNativeMethod(message: SDKMessageLoose): string { +function sdkNativeMethod(message: SDKMessage): string { const subtype = sdkMessageSubtype(message); if (subtype) { return `claude/${message.type}/${subtype}`; @@ -600,7 +819,7 @@ function sdkNativeMethod(message: SDKMessageLoose): string { return `claude/${message.type}`; } -function sdkNativeItemId(message: SDKMessageLoose): string | undefined { +function sdkNativeItemId(message: SDKMessage): string | undefined { if (message.type === "assistant") { const maybeId = (message.message as { id?: unknown }).id; if (typeof maybeId === "string") { @@ -609,6 +828,10 @@ function sdkNativeItemId(message: SDKMessageLoose): string | undefined { return undefined; } + if (message.type === "user") { + return toolResultBlocksFromUserMessage(message)[0]?.toolUseId; + } + if (message.type === "stream_event") { const event = message.event as { type?: unknown; @@ -622,8 +845,10 @@ function sdkNativeItemId(message: SDKMessageLoose): string | undefined { return undefined; } -function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { +function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -673,16 +898,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: observedAt, method: sdkNativeMethod(message), - ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(typeof message.session_id === "string" ? { providerThreadId: message.session_id } : {}), - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), payload: message, }, }, - null, + context.session.threadId, ); }); @@ -735,6 +959,203 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }; }); + 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 && !existing.completionEmitted) { + 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, + blockIndex, + emittedTextDelta: false, + fallbackText: options?.fallbackText ?? "", + streamClosed: options?.streamClosed ?? false, + completionEmitted: false, + }; + turnState.assistantTextBlocks.set(blockIndex, block); + turnState.assistantTextBlockOrder.push(block); + 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, + block: AssistantTextBlockState, + options?: { + readonly force?: boolean; + readonly rawMethod?: string; + readonly rawPayload?: unknown; + }, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState || 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; + if (turnState.assistantTextBlocks.get(block.blockIndex) === block) { + turnState.assistantTextBlocks.delete(block.blockIndex); + } + + 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((block) => ({ + blockIndex: block.blockIndex, + block, + })); + + 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.block, { + rawMethod: "claude/assistant", + rawPayload: message, + }); + } + } + }); + const ensureThreadId = ( context: ClaudeSessionContext, message: SDKMessage, @@ -777,6 +1198,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { cause?: unknown, ): Effect.Effect => Effect.gen(function* () { + if (cause !== undefined) { + void cause; + } const turnState = context.turnState; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -785,16 +1209,13 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(turnState ? { turnId: turnState.turnId } : {}), + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { message, class: "provider_error", ...(cause !== undefined ? { detail: cause } : {}), }, - providerRefs: { - ...providerThreadRef(context), - ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), - }, + providerRefs: nativeProviderRefs(context), }); }); @@ -812,14 +1233,59 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(turnState ? { turnId: turnState.turnId } : {}), + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { message, ...(detail !== undefined ? { detail } : {}), }, - providerRefs: { - ...providerThreadRef(context), - ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + 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, }, }); }); @@ -834,82 +1300,65 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const turnState = context.turnState; if (!turnState) { const stamp = yield* makeEventStamp(); - const fallbackTurnId = context.lastTurnId; yield* offerRuntimeEvent({ type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(fallbackTurnId ? { turnId: fallbackTurnId } : {}), payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: toUnknownRecord(result.modelUsage) } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), ...(typeof result?.total_cost_usd === "number" ? { totalCostUsd: result.total_cost_usd } : {}), ...(errorMessage ? { errorMessage } : {}), }, - providerRefs: fallbackTurnId ? { providerTurnId: String(fallbackTurnId) } : {}, + providerRefs: {}, }); - - const updatedAt = yield* nowIso; - context.session = { - ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt, - lastError: status === "failed" && errorMessage ? errorMessage : undefined, - }; - yield* updateResumeCursor(context); return; } - if (!turnState.messageCompleted) { - if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { - const deltaStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "content.delta", - eventId: deltaStamp.eventId, - provider: PROVIDER, - createdAt: deltaStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(turnState.assistantItemId), - payload: { - streamKind: "assistant_text", - delta: turnState.fallbackAssistantText, - }, - providerRefs: { - ...providerThreadRef(context), - providerTurnId: String(turnState.turnId), - providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), - }, - }); - } - - const stamp = yield* makeEventStamp(); + for (const [index, tool] of context.inFlightTools.entries()) { + const toolStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "item.completed", - eventId: stamp.eventId, + eventId: toolStamp.eventId, provider: PROVIDER, - createdAt: stamp.createdAt, - itemId: asRuntimeItemId(turnState.assistantItemId), + createdAt: toolStamp.createdAt, threadId: context.session.threadId, turnId: turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), payload: { - itemType: "assistant_message", - status: "completed", - title: "Assistant message", + itemType: tool.itemType, + status: status === "completed" ? "completed" : "failed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: tool.input, + }, }, - providerRefs: { - ...providerThreadRef(context), - providerTurnId: turnState.turnId, - providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + 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 block of turnState.assistantTextBlockOrder) { + yield* completeAssistantTextBlock(context, block, { + force: true, + rawMethod: "claude/result", + rawPayload: result ?? { status }, + }); } context.turns.push({ @@ -929,16 +1378,13 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: toUnknownRecord(result.modelUsage) } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), ...(typeof result?.total_cost_usd === "number" ? { totalCostUsd: result.total_cost_usd } : {}), ...(errorMessage ? { errorMessage } : {}), }, - providerRefs: { - ...providerThreadRef(context), - providerTurnId: turnState.turnId, - }, + providerRefs: nativeProviderRefs(context), }); const updatedAt = yield* nowIso; @@ -948,55 +1394,145 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { status: "ready", activeTurnId: undefined, updatedAt, - lastError: status === "failed" && errorMessage ? errorMessage : undefined, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), }; yield* updateResumeCursor(context); }); const handleStreamEvent = ( context: ClaudeSessionContext, - message: SDKMessageLoose, + message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "stream_event") { return; } - const { event } = message; + const event = message.event as ClaudeStreamEvent | undefined; + if (!event) { + return; + } + + if (event.type === "content_block_delta") { + const delta = event.delta; + if (!delta) { + return; + } + if ( + (delta.type === "text_delta" || delta.type === "thinking_delta") && + context.turnState + ) { + const deltaText = + delta.type === "text_delta" + ? delta.text + : typeof delta.thinking === "string" + ? delta.thinking + : ""; + if (deltaText?.length === 0) { + return; + } + const streamKind = streamKindFromDeltaType(delta.type!); + const blockIndex = event.index as number; + const assistantBlockEntry = + delta.type === "text_delta" + ? yield* ensureAssistantTextBlock(context, blockIndex) + : context.turnState.assistantTextBlocks.get(blockIndex) + ? { + blockIndex, + block: context.turnState.assistantTextBlocks.get( + blockIndex, + ) as AssistantTextBlockState, + } + : undefined; + if (assistantBlockEntry?.block && 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 (delta.type === "input_json_delta") { + const tool = context.inFlightTools.get(event.index as number); + if (!tool || typeof delta.partial_json !== "string") { + return; + } - if (event.type === "content_block_delta") { - if ( - event.delta.type === "text_delta" && - event.delta.text.length > 0 && - context.turnState - ) { - if (!context.turnState.emittedTextDelta) { - context.turnState = { - ...context.turnState, - emittedTextDelta: true, - }; + const partialInputJson = tool.partialInputJson + 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 as number, nextTool); + + if ( + !parsedInput || + !nextFingerprint || + tool.lastEmittedInputFingerprint === nextFingerprint + ) { + return; } + + nextTool = { + ...nextTool, + lastEmittedInputFingerprint: nextFingerprint, + }; + context.inFlightTools.set(event.index as number, nextTool); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "content.delta", + type: "item.updated", eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(context.turnState.assistantItemId), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(nextTool.itemId), payload: { - streamKind: streamKindFromDeltaType(event.delta.type), - delta: event.delta.text, - }, - providerRefs: { - ...providerThreadRef(context), - providerTurnId: context.turnState.turnId, - providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + 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", + method: "claude/stream_event/content_block_delta/input_json_delta", payload: message, }, }); @@ -1005,7 +1541,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } if (event.type === "content_block_start") { - const { index, content_block: block } = event; + const index = event.index as number; + const block = event.content_block as Record; + if (block.type === "text") { + yield* ensureAssistantTextBlock(context, index, { + fallbackText: extractContentBlockText(block), + }); + return; + } if ( block.type !== "tool_use" && block.type !== "server_tool_use" && @@ -1014,14 +1557,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return; } - const toolName = block.name; + const toolName = block.name as string; const itemType = classifyToolItemType(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) : {}; - const itemId = block.id; + const itemId = block.id as string; const detail = summarizeToolRequest(toolName, toolInput); + const inputFingerprint = + Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; const tool: ToolInFlight = { itemId, @@ -1029,6 +1574,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { toolName, title: titleForTool(itemType), detail, + input: toolInput, + partialInputJson: "", + ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), }; context.inFlightTools.set(index, tool); @@ -1039,7 +1587,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), payload: { itemType: tool.itemType, @@ -1051,11 +1599,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { input: toolInput, }, }, - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerItemId: ProviderItemId.makeUnsafe(tool.itemId), - }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_start", @@ -1066,39 +1610,125 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } if (event.type === "content_block_stop") { - const { index } = event; + const index = event.index as number; + const assistantBlock = context.turnState?.assistantTextBlocks.get(index); + if (assistantBlock) { + assistantBlock.streamClosed = true; + yield* completeAssistantTextBlock(context, assistantBlock, { + rawMethod: "claude/stream_event/content_block_stop", + rawPayload: message, + }); + return; + } const tool = context.inFlightTools.get(index); if (!tool) { return; } - context.inFlightTools.delete(index); + } + }); - const stamp = yield* makeEventStamp(); + 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.completed", - eventId: stamp.eventId, + type: "item.updated", + eventId: updatedStamp.eventId, provider: PROVIDER, - createdAt: stamp.createdAt, + createdAt: updatedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), payload: { itemType: tool.itemType, - status: "completed", + status: toolResult.isError ? "failed" : "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, }, - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + 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/stream_event/content_block_stop", + method: "claude/user", payload: message, }, }); + + context.inFlightTools.delete(index); } }); @@ -1111,47 +1741,81 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return; } - if (context.turnState) { - context.turnState.items.push(message.message); - const fallbackAssistantText = extractAssistantText(message); - if ( - fallbackAssistantText.length > 0 && - fallbackAssistantText !== context.turnState.fallbackAssistantText - ) { - context.turnState = { - ...context.turnState, - fallbackAssistantText, - }; - } - - const stamp = yield* makeEventStamp(); + // 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: "item.updated", - eventId: stamp.eventId, + type: "turn.started", + eventId: turnStartedStamp.eventId, provider: PROVIDER, - createdAt: stamp.createdAt, + createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(context.turnState.assistantItemId), - payload: { - itemType: "assistant_message", - status: "inProgress", - title: "Assistant message", - data: message.message, - }, + turnId, + payload: {}, providerRefs: { - ...providerThreadRef(context), - providerTurnId: context.turnState.turnId, - providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + ...nativeProviderRefs(context), + providerTurnId: turnId, }, raw: { source: "claude.sdk.message", - method: "claude/assistant", - payload: 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); }); @@ -1166,8 +1830,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } const status = turnStatusFromResult(message); - const errorMessage = - message.subtype === "success" ? undefined : toStringOrUndefined(message.errors?.[0]); + const rawError = message.subtype === "success" ? undefined : message.errors?.[0]; + const errorMessage = typeof rawError === "string" ? rawError : undefined; if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -1178,7 +1842,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSystemMessage = ( context: ClaudeSessionContext, - message: SDKMessageLoose, + message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "system") { @@ -1191,11 +1855,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), - }, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: nativeProviderRefs(context), raw: { source: "claude.sdk.message" as const, method: sdkNativeMethod(message), @@ -1240,9 +1901,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "hook.started", payload: { - hookId: message.hook_id, - hookName: message.hook_name, - hookEvent: message.hook_event, + hookId: message.hook_id!, + hookName: message.hook_name!, + hookEvent: message.hook_event!, }, }); return; @@ -1251,7 +1912,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "hook.progress", payload: { - hookId: message.hook_id, + hookId: message.hook_id!, output: message.output, stdout: message.stdout, stderr: message.stderr, @@ -1263,8 +1924,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "hook.completed", payload: { - hookId: message.hook_id, - outcome: message.outcome, + hookId: message.hook_id!, + outcome: message.outcome!, output: message.output, stdout: message.stdout, stderr: message.stderr, @@ -1277,7 +1938,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.started", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), + taskId: RuntimeTaskId.makeUnsafe(message.task_id!), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), }, @@ -1288,8 +1949,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.progress", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), - description: message.description, + 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 } : {}), }, @@ -1300,8 +1962,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.completed", payload: { - taskId: RuntimeTaskId.makeUnsafe(message.task_id), - status: message.status, + taskId: RuntimeTaskId.makeUnsafe(message.task_id!), + status: message.status as "stopped" | "completed" | "failed", ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), }, @@ -1341,7 +2003,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSdkTelemetryMessage = ( context: ClaudeSessionContext, - message: SDKMessageLoose, + message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { const stamp = yield* makeEventStamp(); @@ -1350,11 +2012,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), - }, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: nativeProviderRefs(context), raw: { source: "claude.sdk.message" as const, method: sdkNativeMethod(message), @@ -1382,7 +2041,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "tool.summary", payload: { - summary: message.summary, + summary: message.summary!, ...((message.preceding_tool_use_ids?.length ?? 0) > 0 ? { precedingToolUseIds: message.preceding_tool_use_ids } : {}), @@ -1397,7 +2056,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "auth.status", payload: { isAuthenticating: message.isAuthenticating, - output: message.output, + ...(message.output ? { output: [message.output] } : {}), ...(message.error ? { error: message.error } : {}), }, }); @@ -1405,7 +2064,6 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } if (message.type === "rate_limit_event") { - storeClaudeRateLimits(message as Record); yield* offerRuntimeEvent({ ...base, type: "account.rate-limits.updated", @@ -1419,7 +2077,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSdkMessage = ( context: ClaudeSessionContext, - message: SDKMessageLoose, + message: SDKMessage, ): Effect.Effect => Effect.gen(function* () { yield* logNativeSdkMessage(context, message); @@ -1430,6 +2088,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* handleStreamEvent(context, message); return; case "user": + yield* handleUserMessage(context, message); return; case "assistant": yield* handleAssistantMessage(context, message); @@ -1456,21 +2115,48 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } }); - const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => - Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => + toError(cause, "Claude runtime stream failed."), + ).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 handleStreamExit = ( + context: ClaudeSessionContext, + exit: Exit.Exit, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) { + return; + } + + if (Exit.isFailure(exit)) { + if (isClaudeInterruptedCause(exit.cause)) { + if (context.turnState) { + yield* completeTurn( + context, + "interrupted", + interruptionMessageFromClaudeCause(exit.cause), + ); } - const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); - yield* emitRuntimeError(context, message, cause); + } else { + const message = messageFromClaudeStreamCause( + exit.cause, + "Claude runtime stream failed.", + ); + yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); yield* completeTurn(context, "failed", message); - }), - ), - ); + } + } else if (context.turnState) { + yield* completeTurn(context, "interrupted", "Claude runtime stream ended."); + } + + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); const stopSessionInternal = ( context: ClaudeSessionContext, @@ -1490,17 +2176,13 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType: pending.requestType, decision: "cancel", }, - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerRequestId: requestId, - }, + providerRefs: nativeProviderRefs(context), }); } context.pendingApprovals.clear(); @@ -1511,8 +2193,18 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Queue.shutdown(context.promptQueue); - // The SDK may throw if internal session files were already cleaned up - yield* Effect.sync(() => context.query.close()).pipe(Effect.ignore); + const streamFiber = context.streamFiber; + context.streamFiber = undefined; + if (streamFiber && streamFiber.pollUnsafe() === undefined) { + yield* Fiber.interrupt(streamFiber); + } + + // @effect-diagnostics-next-line tryCatchInEffectGen:off + try { + context.query.close(); + } catch (cause) { + yield* emitRuntimeError(context, "Failed to close Claude runtime query.", cause); + } const updatedAt = yield* nowIso; context.session = { @@ -1564,7 +2256,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return Effect.succeed(context); }; - const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + const startSession: ClaudeAdapterShape["startSession"] = (input) => Effect.gen(function* () { if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ @@ -1576,7 +2268,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); - const threadId = resumeState?.threadId ?? input.threadId; + const threadId = input.threadId; + const existingResumeSessionId = resumeState?.resume; + const newSessionId = + existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; + const sessionId = existingResumeSessionId ?? newSessionId; const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( @@ -1586,10 +2282,124 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ); 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(); + let aborted = false; + 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 as string | undefined, + }), + 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; + } + aborted = true; + 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 as string | undefined, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/AskUserQuestion/resolved", + payload: { answers }, + }, + }); + + if (aborted) { + return { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult; + } + + // 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* () { @@ -1601,6 +2411,35 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } 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 { @@ -1629,7 +2468,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType, @@ -1640,13 +2481,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), }, }, - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState - ? { providerTurnId: String(context.turnState.turnId) } - : {}), - providerRequestId: requestId, - }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID as string | undefined, + }), raw: { source: "claude.sdk.permission", method: "canUseTool/request", @@ -1667,7 +2504,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); }; - callbackOptions.signal?.addEventListener("abort", onAbort, { + callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); @@ -1681,19 +2518,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { provider: PROVIDER, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType, decision, }, - providerRefs: { - ...providerThreadRef(context), - ...(context.turnState - ? { providerTurnId: String(context.turnState.turnId) } - : {}), - providerRequestId: requestId, - }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID as string | undefined, + }), raw: { source: "claude.sdk.permission", method: "canUseTool/decision", @@ -1723,63 +2558,53 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }), ); - const providerOptions = input.providerOptions?.claudeCode; - const claudeCodeModelOptions = input.modelOptions?.claudeCode; + 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 effort = claudeCodeModelOptions?.effort as EffortLevel | undefined; - - const pathToClaudeCodeExecutable = yield* Effect.try({ - try: () => - (providerOptions?.binaryPath as string | undefined) ?? defaultClaudeSdkCliPath(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: toMessage(cause, "Failed to resolve Claude Code executable."), - cause, - }), - }); + const settings = { + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), - pathToClaudeCodeExecutable, + pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens as number } + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } : {}), - ...(effort ? { effort } : {}), - ...(resumeState?.resume ? { resume: resumeState.resume } : {}), - ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), + ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), + ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: sanitizedEnv(), - ...(isDesktopRuntime() - ? { - stderr: (message: string) => { - const trimmed = message.trimEnd(); - if (trimmed.length > 0) { - logger.warn(`[stderr] ${trimmed}`); - } - }, - } - : {}), + env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), }; - logDesktopClaudeDiagnostic("starting Claude query", { - blockedEnvKeys: Array.from(SPAWN_ENV_BLOCKLIST).toSorted(), - inheritedDiagnosticEnvKeys: diagnosticEnvKeys(process.env), - forwardedDiagnosticEnvKeys: diagnosticEnvKeys(queryOptions.env ?? {}), - model: input.model, - cwd: input.cwd, - }); - const queryRuntime = yield* Effect.try({ try: () => createQuery({ @@ -1795,13 +2620,6 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }), }); - // Guard against duplicate threadId: stop/cleanup any existing session AFTER the - // replacement runtime is successfully created so the thread is never left unusable. - const existingContext = sessions.get(threadId); - if (existingContext) { - yield* stopSessionInternal(existingContext, { emitExitEvent: true }); - } - const session: ProviderSession = { threadId, provider: PROVIDER, @@ -1809,9 +2627,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), resumeCursor: { ...(threadId ? { threadId } : {}), - ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(sessionId ? { resume: sessionId } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), @@ -1825,13 +2644,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { session, promptQueue, query: queryRuntime, + streamFiber: undefined, startedAt, - resumeSessionId: resumeState?.resume, + basePermissionMode: permissionMode, + resumeSessionId: sessionId, pendingApprovals, + pendingUserInputs, turns: [], inFlightTools, turnState: undefined, - lastTurnId: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, @@ -1861,10 +2682,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { config: { ...(input.model ? { model: input.model } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), + ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(providerOptions?.maxThinkingTokens !== undefined ? { maxThinkingTokens: providerOptions.maxThinkingTokens } : {}), + ...(fastMode ? { fastMode: true } : {}), }, }, providerRefs: {}, @@ -1883,23 +2706,31 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { providerRefs: {}, }); - yield* runSdkStream(context).pipe(Effect.forkDetach); + const streamFiber = Effect.runFork(runSdkStream(context)); + context.streamFiber = streamFiber; + streamFiber.addObserver((exit) => { + if (context.stopped) { + return; + } + if (context.streamFiber === streamFiber) { + context.streamFiber = undefined; + } + Effect.runFork(handleStreamExit(context, exit)); + }); return { ...session, }; }); - const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const context = yield* requireSession(input.threadId); if (context.turnState) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, - }); + // 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) { @@ -1909,30 +2740,41 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }); } - // Validate the user message BEFORE mutating any session state so that - // a buildUserMessage failure leaves the session unchanged. - const message = yield* buildUserMessage(input); + // 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, - assistantItemId: yield* Random.nextUUIDv4, startedAt: yield* nowIso, items: [], - messageCompleted: false, - emittedTextDelta: false, - fallbackAssistantText: "", + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + nextSyntheticAssistantBlockIndex: -1, }; const updatedAt = yield* nowIso; context.turnState = turnState; - context.lastTurnId = turnId; context.session = { ...context.session, status: "running", activeTurnId: turnId, updatedAt, - lastError: undefined, }; const turnStartedStamp = yield* makeEventStamp(); @@ -1944,9 +2786,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { threadId: context.session.threadId, turnId, payload: input.model ? { model: input.model } : {}, - providerRefs: { - providerTurnId: String(turnId), - }, + providerRefs: {}, + }); + + const message = yield* buildUserMessageEffect(input, { + fileSystem, + stateDir: serverConfig.stateDir, }); yield* Queue.offer(context.promptQueue, { @@ -1963,7 +2808,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }; }); - const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => + const interruptTurn: ClaudeAdapterShape["interruptTurn"] = (threadId, _turnId) => Effect.gen(function* () { const context = yield* requireSession(threadId); yield* Effect.tryPromise({ @@ -1972,24 +2817,22 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }); }); - const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + const readThread: ClaudeAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); return yield* snapshotThread(context); }); - const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, _numTurns) => + const rollbackThread: ClaudeAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - yield* requireSession(threadId); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread.rollback", - detail: - "Claude Code rollback is not supported without rewinding the underlying provider session.", - }); + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); }); - const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + const respondToRequest: ClaudeAdapterShape["respondToRequest"] = ( threadId, requestId, decision, @@ -2009,20 +2852,27 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Deferred.succeed(pending.decision, decision); }); - const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + const respondToUserInput: ClaudeAdapterShape["respondToUserInput"] = ( threadId, requestId, - _answers, + answers, ) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "item/tool/requestUserInput", - detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, - }), - ); + 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: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); yield* stopSessionInternal(context, { @@ -2030,16 +2880,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }); }); - const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + const listSessions: ClaudeAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const context = sessions.get(threadId); return context !== undefined && !context.stopped; }); - const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + const stopAll: ClaudeAdapterShape["stopAll"] = () => Effect.forEach( sessions, ([, context]) => @@ -2075,12 +2925,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { hasSession, stopAll, streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies ClaudeCodeAdapterShape; + } satisfies ClaudeAdapterShape; }); } -export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); +export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); -export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { - return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) { + return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options)); } diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts deleted file mode 100644 index 46c444f2a3..0000000000 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ /dev/null @@ -1,958 +0,0 @@ -import type { - Options as ClaudeQueryOptions, - PermissionMode, - PermissionResult, - SDKMessage, - SDKUserMessage, -} from "@anthropic-ai/claude-agent-sdk"; -import * as Path from "node:path"; -import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber, Random, Stream } from "effect"; - -import { ProviderAdapterValidationError } from "../Errors.ts"; -import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; -import { - makeClaudeCodeAdapterLive, - type ClaudeCodeAdapterLiveOptions, -} from "./ClaudeCodeAdapter.ts"; - -class FakeClaudeQuery implements AsyncIterable { - private readonly queue: Array = []; - private readonly resolvers: Array<(value: IteratorResult) => void> = []; - private done = false; - - public readonly interruptCalls: Array = []; - public readonly setModelCalls: Array = []; - public readonly setPermissionModeCalls: Array = []; - public readonly setMaxThinkingTokensCalls: Array = []; - public closeCalls = 0; - - emit(message: SDKMessage): void { - if (this.done) { - return; - } - const resolver = this.resolvers.shift(); - if (resolver) { - resolver({ done: false, value: message }); - return; - } - this.queue.push(message); - } - - finish(): void { - if (this.done) { - return; - } - this.done = true; - for (const resolver of this.resolvers.splice(0)) { - resolver({ done: true, value: undefined }); - } - } - - readonly interrupt = async (): Promise => { - this.interruptCalls.push(undefined); - }; - - readonly setModel = async (model?: string): Promise => { - this.setModelCalls.push(model); - }; - - readonly setPermissionMode = async (mode: PermissionMode): Promise => { - this.setPermissionModeCalls.push(mode); - }; - - readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise => { - this.setMaxThinkingTokensCalls.push(maxThinkingTokens); - }; - - readonly close = (): void => { - this.closeCalls += 1; - this.finish(); - }; - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: () => { - if (this.queue.length > 0) { - const value = this.queue.shift(); - if (value) { - return Promise.resolve({ - done: false, - value, - }); - } - } - if (this.done) { - return Promise.resolve({ - done: true, - value: undefined, - }); - } - return new Promise((resolve) => { - this.resolvers.push(resolve); - }); - }, - }; - } -} - -interface Harness { - readonly layer: ReturnType; - readonly query: FakeClaudeQuery; - readonly getLastCreateQueryInput: () => - | { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - } - | undefined; -} - -function makeHarness(config?: { - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"]; -}): Harness { - const query = new FakeClaudeQuery(); - let createInput: - | { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - } - | undefined; - - const adapterOptions: ClaudeCodeAdapterLiveOptions = { - createQuery: (input) => { - createInput = input; - return query; - }, - ...(config?.nativeEventLogger - ? { - nativeEventLogger: config.nativeEventLogger, - } - : {}), - ...(config?.nativeEventLogPath - ? { - nativeEventLogPath: config.nativeEventLogPath, - } - : {}), - }; - - return { - layer: makeClaudeCodeAdapterLive(adapterOptions), - query, - getLastCreateQueryInput: () => createInput, - }; -} - -function makeDeterministicRandomService(seed = 0x1234_5678): { - nextIntUnsafe: () => number; - nextDoubleUnsafe: () => number; -} { - let state = seed >>> 0; - const nextIntUnsafe = (): number => { - state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0; - return state; - }; - - return { - nextIntUnsafe, - nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000, - }; -} - -const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); -const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); - -describe("ClaudeCodeAdapterLive", () => { - it.effect("returns validation error for non-claudeCode provider on startSession", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - const result = yield* adapter - .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.deepEqual( - result.failure, - new ProviderAdapterValidationError({ - provider: "claudeCode", - operation: "startSession", - issue: "Expected provider 'claudeCode' but received 'codex'.", - }), - ); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("derives bypass permission mode from full-access runtime policy", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.permissionMode, "bypassPermissions"); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); - assert.equal(Path.basename(createInput?.options.pathToClaudeCodeExecutable ?? ""), "cli.js"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - providerOptions: { - claudeCode: { - permissionMode: "plan", - }, - }, - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.permissionMode, "plan"); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("strips Electron bootstrap env vars before starting Claude query", () => { - const harness = makeHarness(); - const originalRunAsNode = process.env.ELECTRON_RUN_AS_NODE; - const originalRendererPort = process.env.ELECTRON_RENDERER_PORT; - const originalClaudeCode = process.env.CLAUDECODE; - const originalPath = process.env.PATH; - - process.env.ELECTRON_RUN_AS_NODE = "1"; - process.env.ELECTRON_RENDERER_PORT = "54321"; - process.env.CLAUDECODE = "1"; - process.env.PATH = "/usr/bin:/bin"; - - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.isDefined(createInput); - assert.equal(createInput?.options.env?.ELECTRON_RUN_AS_NODE, undefined); - assert.equal(createInput?.options.env?.ELECTRON_RENDERER_PORT, undefined); - assert.equal(createInput?.options.env?.CLAUDECODE, undefined); - assert.equal(createInput?.options.env?.PATH, "/usr/bin:/bin"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalRunAsNode === undefined) { - delete process.env.ELECTRON_RUN_AS_NODE; - } else { - process.env.ELECTRON_RUN_AS_NODE = originalRunAsNode; - } - - if (originalRendererPort === undefined) { - delete process.env.ELECTRON_RENDERER_PORT; - } else { - process.env.ELECTRON_RENDERER_PORT = originalRendererPort; - } - - if (originalClaudeCode === undefined) { - delete process.env.CLAUDECODE; - } else { - process.env.CLAUDECODE = originalClaudeCode; - } - - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - }), - ), - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - model: "claude-sonnet-4-5", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-1", - uuid: "stream-1", - parent_tool_use_id: null, - event: { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: "Hi", - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-1", - uuid: "stream-2", - parent_tool_use_id: null, - event: { - type: "content_block_start", - index: 1, - content_block: { - type: "tool_use", - id: "tool-1", - name: "Bash", - input: { - command: "ls", - }, - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-1", - uuid: "stream-3", - parent_tool_use_id: null, - event: { - type: "content_block_stop", - index: 1, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "assistant", - session_id: "sdk-session-1", - uuid: "assistant-1", - parent_tool_use_id: null, - message: { - id: "assistant-message-1", - content: [{ type: "text", text: "Hi" }], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-1", - uuid: "result-1", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "thread.started", - "content.delta", - "item.started", - "item.completed", - "item.updated", - "item.completed", - "turn.completed", - ], - ); - - const turnStarted = runtimeEvents[3]; - assert.equal(turnStarted?.type, "turn.started"); - if (turnStarted?.type === "turn.started") { - assert.equal(String(turnStarted.turnId), String(turn.turnId)); - } - - const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); - assert.equal(deltaEvent?.type, "content.delta"); - if (deltaEvent?.type === "content.delta") { - assert.equal(deltaEvent.payload.delta, "Hi"); - assert.equal(String(deltaEvent.turnId), String(turn.turnId)); - } - - const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); - assert.equal(toolStarted?.type, "item.started"); - if (toolStarted?.type === "item.started") { - assert.equal(toolStarted.payload.itemType, "command_execution"); - } - - const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; - assert.equal(turnCompleted?.type, "turn.completed"); - if (turnCompleted?.type === "turn.completed") { - assert.equal(String(turnCompleted.turnId), String(turn.turnId)); - assert.equal(turnCompleted.payload.state, "completed"); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect( - "emits completion only after turn result when assistant frames arrive before deltas", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - harness.query.emit({ - type: "assistant", - session_id: "sdk-session-early-assistant", - uuid: "assistant-early", - parent_tool_use_id: null, - message: { - id: "assistant-message-early", - content: [ - { type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }, - ], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "stream_event", - session_id: "sdk-session-early-assistant", - uuid: "stream-early", - parent_tool_use_id: null, - event: { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: "Late text", - }, - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-early-assistant", - uuid: "result-early", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "thread.started", - "item.updated", - "content.delta", - "item.completed", - "turn.completed", - ], - ); - - const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta"); - const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed"); - assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); - - const deltaEvent = runtimeEvents[deltaIndex]; - assert.equal(deltaEvent?.type, "content.delta"); - if (deltaEvent?.type === "content.delta") { - assert.equal(deltaEvent.payload.delta, "Late text"); - assert.equal(String(deltaEvent.turnId), String(turn.turnId)); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); - - it.effect("falls back to assistant payload text when stream deltas are absent", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - harness.query.emit({ - type: "assistant", - session_id: "sdk-session-fallback-text", - uuid: "assistant-fallback", - parent_tool_use_id: null, - message: { - id: "assistant-message-fallback", - content: [{ type: "text", text: "Fallback hello" }], - }, - } as unknown as SDKMessage); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-fallback-text", - uuid: "result-fallback", - } as unknown as SDKMessage); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.deepEqual( - runtimeEvents.map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "turn.started", - "thread.started", - "item.updated", - "content.delta", - "item.completed", - "turn.completed", - ], - ); - - const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); - assert.equal(deltaEvent?.type, "content.delta"); - if (deltaEvent?.type === "content.delta") { - assert.equal(deltaEvent.payload.delta, "Fallback hello"); - assert.equal(String(deltaEvent.turnId), String(turn.turnId)); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("does not fabricate provider thread ids before first SDK session_id", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - assert.equal(session.threadId, THREAD_ID); - assert.equal( - "resume" in (session.resumeCursor as Record), - false, - "resumeCursor should not have a fabricated 'resume' (SDK session_id) before stream provides one", - ); - - 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); - assert.equal( - (sessionStarted.providerRefs as Record | undefined)?.providerThreadId, - undefined, - "providerRefs should not contain a fabricated providerThreadId before stream provides one", - ); - } - - const threadStarted = runtimeEvents[4]; - assert.equal(threadStarted?.type, "thread.started"); - if (threadStarted?.type === "thread.started") { - assert.equal(threadStarted.threadId, THREAD_ID); - assert.equal( - threadStarted.payload.providerThreadId, - "sdk-thread-real", - "thread.started should carry the real SDK session_id in payload.providerThreadId", - ); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("bridges approval request/response lifecycle through canUseTool", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "approval-required", - }); - - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); - - const createInput = harness.getLastCreateQueryInput(); - const canUseTool = createInput?.options.canUseTool; - assert.equal(typeof canUseTool, "function"); - if (!canUseTool) { - return; - } - - const permissionPromise = canUseTool( - "Bash", - { command: "pwd" }, - { - signal: new AbortController().signal, - suggestions: [ - { - type: "setMode", - mode: "default", - destination: "session", - }, - ], - toolUseID: "tool-use-1", - }, - ); - - const requested = yield* Stream.runHead(adapter.streamEvents); - assert.equal(requested._tag, "Some"); - if (requested._tag !== "Some") { - return; - } - assert.equal(requested.value.type, "request.opened"); - if (requested.value.type !== "request.opened") { - return; - } - const runtimeRequestId = requested.value.requestId; - assert.equal(typeof runtimeRequestId, "string"); - if (runtimeRequestId === undefined) { - return; - } - - yield* adapter.respondToRequest( - session.threadId, - ApprovalRequestId.makeUnsafe(runtimeRequestId), - "accept", - ); - - const resolved = yield* Stream.runHead(adapter.streamEvents); - assert.equal(resolved._tag, "Some"); - if (resolved._tag !== "Some") { - return; - } - assert.equal(resolved.value.type, "request.resolved"); - if (resolved.value.type !== "request.resolved") { - return; - } - assert.equal(resolved.value.requestId, requested.value.requestId); - assert.equal(resolved.value.payload.decision, "accept"); - - const permissionResult = yield* Effect.promise(() => permissionPromise); - assert.equal((permissionResult as PermissionResult).behavior, "allow"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("passes parsed resume cursor values to Claude query options", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: RESUME_THREAD_ID, - provider: "claudeCode", - resumeCursor: { - threadId: "resume-thread-1", - resume: "550e8400-e29b-41d4-a716-446655440000", - resumeSessionAt: "assistant-99", - turnCount: 3, - }, - runtimeMode: "full-access", - }); - - assert.equal(session.threadId, "resume-thread-1"); - assert.deepEqual(session.resumeCursor, { - threadId: "resume-thread-1", - resume: "550e8400-e29b-41d4-a716-446655440000", - resumeSessionAt: "assistant-99", - turnCount: 3, - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000"); - assert.equal(createInput?.options.resumeSessionAt, "assistant-99"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("does not synthesize resume session id from generated thread ids", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - assert.equal("resume" in (session.resumeCursor as Record), false); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.resume, undefined); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect( - "rollbackThread returns ProviderAdapterRequestError because Claude Code does not support rewinding", - () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - - const result = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); - assert.equal(result._tag, "ProviderAdapterRequestError"); - if (result._tag === "ProviderAdapterRequestError") { - assert.equal(result.method, "thread.rollback"); - assert.ok(result.detail?.includes("not supported")); - } - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }, - ); - - it.effect("updates model on sendTurn when model override is provided", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - model: "claude-opus-4-6", - attachments: [], - }); - - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("writes provider-native observability records when enabled", () => { - const nativeEvents: Array<{ - event?: { - provider?: string; - method?: string; - threadId?: string; - turnId?: string; - }; - }> = []; - const harness = makeHarness({ - nativeEventLogger: { - filePath: "memory://claude-native-events", - write: (event) => { - nativeEvents.push(event as (typeof nativeEvents)[number]); - return Effect.void; - }, - close: () => Effect.void, - }, - }); - return Effect.gen(function* () { - const adapter = yield* ClaudeCodeAdapter; - - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeCode", - runtimeMode: "full-access", - }); - 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 === "claudeCode"), - true, - ); - assert.equal( - nativeEvents.some((record) => String(record.event?.threadId) === String(session.threadId)), - 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, - ); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); -}); 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..ca6c9b44f2 --- /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 394b275456..cc9ae0bc8b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -172,7 +172,7 @@ validationLayer("CodexAdapterLive validation", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .startSession({ - provider: "claudeCode", + provider: "claudeAgent", threadId: asThreadId("thread-1"), runtimeMode: "full-access", }) @@ -184,7 +184,7 @@ validationLayer("CodexAdapterLive validation", (it) => { new ProviderAdapterValidationError({ provider: "codex", operation: "startSession", - issue: "Expected provider 'codex' but received 'claudeCode'.", + issue: "Expected provider 'codex' but received 'claudeAgent'.", }), ); assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); @@ -845,6 +845,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { } }), ); + + it.effect("prefers manager-assigned turn ids for Codex task events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-task-started-parent-turn"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-parent"), + createdAt: new Date().toISOString(), + method: "codex/event/task_started", + payload: { + id: "turn-child", + msg: { + type: "task_started", + turn_id: "turn-child", + collaboration_mode_kind: "default", + }, + conversationId: "child-provider-thread", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "task.started"); + if (firstEvent.value.type !== "task.started") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-parent"); + assert.equal(firstEvent.value.providerRefs?.providerTurnId, "turn-parent"); + assert.equal(firstEvent.value.payload.taskId, "turn-child"); + }), + ); }); afterAll(() => { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 7317fdea84..13be631b58 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -107,6 +107,14 @@ function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function toTurnId(value: string | undefined): TurnId | undefined { + return value?.trim() ? TurnId.makeUnsafe(value) : undefined; +} + +function toProviderItemId(value: string | undefined): ProviderItemId | undefined { + return value?.trim() ? ProviderItemId.makeUnsafe(value) : undefined; +} + function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { switch (value) { case "completed": @@ -413,27 +421,27 @@ function codexEventBase( ): Omit { const payload = asObject(event.payload); const msg = codexEventMessage(payload); - const turnId = asString(msg?.turn_id) ?? asString(msg?.turnId); - const itemId = asString(msg?.item_id) ?? asString(msg?.itemId); + const turnId = event.turnId ?? toTurnId(asString(msg?.turn_id) ?? asString(msg?.turnId)); + const itemId = event.itemId ?? toProviderItemId(asString(msg?.item_id) ?? asString(msg?.itemId)); const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); const base = runtimeEventBase(event, canonicalThreadId); const providerRefs = base.providerRefs ? { ...base.providerRefs, ...(turnId ? { providerTurnId: turnId } : {}), - ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(itemId ? { providerItemId: itemId } : {}), ...(requestId ? { providerRequestId: requestId } : {}), } : { ...(turnId ? { providerTurnId: turnId } : {}), - ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(itemId ? { providerItemId: itemId } : {}), ...(requestId ? { providerRequestId: requestId } : {}), }; return { ...base, - ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}), - ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}), + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId: asRuntimeItemId(itemId) } : {}), ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), }; diff --git a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts index ec40f84752..a3bcee6758 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterConformance.test.ts @@ -7,7 +7,7 @@ import { AmpServerManager } from "../../ampServerManager.ts"; import { GeminiCliServerManager } from "../../geminiCliServerManager.ts"; import { ServerConfig } from "../../config.ts"; import { makeAmpAdapterLive } from "./AmpAdapter.ts"; -import { makeClaudeCodeAdapterLive } from "./ClaudeCodeAdapter.ts"; +import { makeClaudeAdapterLive } from "./ClaudeAdapter.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; import { makeCopilotAdapterLive } from "./CopilotAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; @@ -18,7 +18,7 @@ import { validateProviderAdapterConformance, } from "../Services/ProviderAdapter.ts"; import { AmpAdapter } from "../Services/AmpAdapter.ts"; -import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; @@ -57,7 +57,7 @@ const copilotLayer = makeCopilotAdapterLive({ Layer.provideMerge(NodeServices.layer), ); -const claudeLayer = makeClaudeCodeAdapterLive({ +const claudeLayer = makeClaudeAdapterLive({ createQuery: () => ({ [Symbol.asyncIterator]: async function* () { @@ -69,7 +69,10 @@ const claudeLayer = makeClaudeCodeAdapterLive({ setMaxThinkingTokens: async () => undefined, close: () => undefined, }) as never, -}); +}).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); const cursorLayer = makeCursorAdapterLive({ createProcess: () => ({}) as never, @@ -104,11 +107,11 @@ describe("provider adapter conformance", () => { ), }, { - provider: "claudeCode" as const, + provider: "claudeAgent" as const, load: () => Effect.runPromise( Effect.gen(function* () { - return yield* ClaudeCodeAdapter; + return yield* ClaudeAdapter; }).pipe(Effect.provide(claudeLayer)), ), }, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 6b52803dd3..cce8d43c9c 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,7 +5,7 @@ import type { ProviderKind } from "@t3tools/contracts"; import { Effect, Layer, Stream } from "effect"; import { ProviderUnsupportedError } from "../Errors.ts"; -import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; +import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; @@ -34,9 +34,9 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; -const fakeClaudeAdapter: ClaudeCodeAdapterShape = { - provider: "claudeCode", - capabilities: getProviderCapabilities("claudeCode"), +const fakeClaudeAdapter: ClaudeAdapterShape = { + provider: "claudeAgent", + capabilities: getProviderCapabilities("claudeAgent"), startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -159,7 +159,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(CopilotAdapter, fakeCopilotAdapter), - Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), Layer.succeed(CursorAdapter, fakeCursorAdapter), Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), Layer.succeed(GeminiCliAdapter, fakeGeminiCliAdapter), @@ -177,7 +177,7 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const copilot = yield* registry.getByProvider("copilot"); - const claude = yield* registry.getByProvider("claudeCode"); + const claude = yield* registry.getByProvider("claudeAgent"); const cursor = yield* registry.getByProvider("cursor"); const opencode = yield* registry.getByProvider("opencode"); const geminiCli = yield* registry.getByProvider("geminiCli"); @@ -197,7 +197,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.deepEqual(providers, [ "codex", "copilot", - "claudeCode", + "claudeAgent", "cursor", "opencode", "geminiCli", diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index aed87db0ac..fdee2162b1 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. * @@ -16,7 +16,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; -import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; @@ -37,7 +37,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption : [ yield* CodexAdapter, yield* CopilotAdapter, - yield* ClaudeCodeAdapter, + yield* ClaudeAdapter, yield* CursorAdapter, yield* OpenCodeAdapter, yield* GeminiCliAdapter, 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 777839c97e..86c0050ca2 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -43,6 +43,7 @@ const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const GEMINI_CLI_PROVIDER = "geminiCli" as const; const COPILOT_PROVIDER = "copilot" as const; +const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -61,12 +62,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( @@ -283,6 +279,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< @@ -679,13 +696,197 @@ export const checkCopilotProviderStatus: Effect.Effect = E }, ); +// ── 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 healthCheckFiber = yield* Effect.all( - [checkCodexProviderStatus, checkGeminiCliProviderStatus, checkCopilotProviderStatus], + [ + checkCodexProviderStatus, + checkGeminiCliProviderStatus, + checkCopilotProviderStatus, + checkClaudeProviderStatus, + ], { concurrency: "unbounded" }, ).pipe(Effect.forkScoped); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 41d9a098a6..3a96b5ec87 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -53,15 +53,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: - | "codex" - | "copilot" - | "claudeCode" - | "cursor" - | "opencode" - | "geminiCli" - | "amp" - | "kilo"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -83,7 +75,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, @@ -202,9 +194,21 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); }; + const updateSession = ( + threadId: ThreadId, + update: (session: ProviderSession) => ProviderSession, + ): void => { + const existing = sessions.get(threadId); + if (!existing) { + return; + } + sessions.set(threadId, update(existing)); + }; + return { adapter, emit, + updateSession, startSession, sendTurn, interruptTurn, @@ -224,15 +228,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); - const claude = makeFakeCodexAdapter("claudeCode"); + const claude = makeFakeCodexAdapter("claudeAgent"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : provider === "claudeCode" + : provider === "claudeAgent" ? Effect.succeed(claude.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeCode"]), + listProviders: () => Effect.succeed(["codex", "claudeAgent"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -355,16 +359,29 @@ it.effect( Layer.provide(firstDirectoryLayer), Layer.provide(AnalyticsService.layerTest), ); + const updatedResumeCursor = { + threadId: asThreadId("thread-1"), + resume: "resume-session-1", + resumeSessionAt: "assistant-message-1", + turnCount: 1, + }; const startedSession = yield* Effect.gen(function* () { const provider = yield* ProviderService; const threadId = asThreadId("thread-1"); - return yield* provider.startSession(threadId, { + const session = yield* provider.startSession(threadId, { provider: "codex", cwd: "/tmp/project", runtimeMode: "full-access", threadId, }); + firstCodex.updateSession(threadId, (existing) => ({ + ...existing, + status: "ready", + resumeCursor: updatedResumeCursor, + updatedAt: new Date(Date.now() + 1_000).toISOString(), + })); + return session; }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { @@ -374,7 +391,7 @@ it.effect( assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { assert.equal(persistedAfterStopAll.value.status, "stopped"); - assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, updatedResumeCursor); } const secondCodex = makeFakeCodexAdapter(); @@ -417,7 +434,7 @@ it.effect( }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project"); - assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); + assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor); assert.equal(startPayload.threadId, startedSession.threadId); } assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); @@ -504,24 +521,24 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + 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: "claudeCode", + provider: "claudeAgent", threadId: asThreadId("thread-claude"), cwd: "/tmp/project-claude", runtimeMode: "full-access", }); - assert.equal(session.provider, "claudeCode"); + 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, "claudeCode"); + assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.cwd, "/tmp/project-claude"); } }), @@ -607,6 +624,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; @@ -672,6 +740,96 @@ routing.layer("ProviderServiceLive routing", (it) => { } }), ); + + it.effect("reuses persisted resume cursor when startSession is called after a restart", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstClaude = makeFakeCodexAdapter("claudeAgent"); + const firstRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "claudeAgent" + ? Effect.succeed(firstClaude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["claudeAgent"]), + }; + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const initial = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-claude-start"), { + provider: "claudeAgent", + threadId: asThreadId("thread-claude-start"), + cwd: "/tmp/project-claude-start", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstProviderLayer)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.listSessions(); + }).pipe(Effect.provide(firstProviderLayer)); + + const secondClaude = makeFakeCodexAdapter("claudeAgent"); + const secondRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "claudeAgent" + ? Effect.succeed(secondClaude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["claudeAgent"]), + }; + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + secondClaude.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(initial.threadId, { + provider: "claudeAgent", + threadId: initial.threadId, + cwd: "/tmp/project-claude-start", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondClaude.startSession.mock.calls.length, 1); + const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude-start"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const fanout = makeProviderServiceLayer(); @@ -689,7 +847,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => Ref.update(eventsRef, (current) => [...current, event]), ).pipe(Effect.forkChild); - yield* sleep(20); + yield* sleep(50); const completedEvent: LegacyProviderRuntimeEvent = { type: "turn.completed", @@ -702,7 +860,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }; fanout.codex.emit(completedEvent); - yield* sleep(20); + yield* sleep(50); const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); @@ -728,7 +886,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])), Effect.forkChild, ); - yield* sleep(20); + yield* sleep(50); fanout.codex.emit({ type: "tool.started", @@ -792,7 +950,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Stream.runForEach(() => Effect.fail("listener crash")), Effect.forkChild, ); - yield* sleep(20); + yield* sleep(50); const events: ReadonlyArray = [ { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 5117bcee04..2c2b6051b4 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -97,7 +97,12 @@ function redactProviderOptions(providerOptions: unknown): unknown { function toRuntimePayloadFromSession( session: ProviderSession, - extra?: { readonly providerOptions?: unknown }, + extra?: { + readonly modelOptions?: unknown; + readonly providerOptions?: unknown; + readonly lastRuntimeEvent?: string; + readonly lastRuntimeEventAt?: string; + }, ): Record { const safeProviderOptions = extra?.providerOptions !== undefined ? redactProviderOptions(extra.providerOptions) : undefined; @@ -106,10 +111,26 @@ function toRuntimePayloadFromSession( model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), ...(safeProviderOptions !== undefined ? { providerOptions: safeProviderOptions } : {}), + ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), + ...(extra?.lastRuntimeEventAt !== undefined + ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } + : {}), }; } +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 { @@ -169,7 +190,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, - extra?: { readonly providerOptions?: unknown }, + extra?: { + readonly modelOptions?: unknown; + readonly providerOptions?: unknown; + readonly lastRuntimeEvent?: string; + readonly lastRuntimeEventAt?: string; + }, ) => directory.upsert({ threadId, @@ -184,7 +210,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider), ); - const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => publishRuntimeEvent(event); @@ -241,12 +266,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 !== undefined ? { providerOptions: persistedProviderOptions } : {}), @@ -317,8 +344,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId, provider: parsed.provider ?? "codex", }; + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const effectiveResumeCursor = + input.resumeCursor ?? + (persistedBinding?.provider === input.provider + ? persistedBinding.resumeCursor + : undefined); const adapter = yield* registry.getByProvider(input.provider); - const session = yield* adapter.startSession(input); + const session = yield* adapter.startSession({ + ...input, + ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + }); if (session.provider !== adapter.provider) { return yield* toValidationError( @@ -328,6 +364,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId, { + modelOptions: input.modelOptions, providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { @@ -549,6 +586,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const runStopAll = () => Effect.gen(function* () { const threadIds = yield* directory.listThreadIds(); + const activeSessions = yield* Effect.forEach(adapters, (adapter) => + adapter.listSessions(), + ).pipe( + Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)), + ); + yield* Effect.forEach(activeSessions, (session) => + upsertSessionBinding(session, session.threadId, { + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt: new Date().toISOString(), + }), + ).pipe(Effect.asVoid); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); yield* Effect.forEach(threadIds, (threadId) => directory.getBinding(threadId).pipe( diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 942d04fd0e..9afe27dda6 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 937a883dbf..26f62e4dd1 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -25,7 +25,7 @@ function decodeProviderKind( if ( providerName === "codex" || providerName === "copilot" || - providerName === "claudeCode" || + providerName === "claudeAgent" || providerName === "cursor" || providerName === "opencode" || providerName === "geminiCli" || 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/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts deleted file mode 100644 index 6ef6876061..0000000000 --- a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract. - * - * This service owns Claude runtime/session semantics and emits canonical - * provider runtime events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `ServiceMap.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "claudeCode"` context. - * - * @module ClaudeCodeAdapter - */ -import { ServiceMap } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -/** - * ClaudeCodeAdapterShape - Service API for the Claude Code provider adapter. - */ -export interface ClaudeCodeAdapterShape extends ProviderAdapterShape { - readonly provider: "claudeCode"; -} - -/** - * ClaudeCodeAdapter - Service tag for Claude Code provider adapter operations. - */ -export class ClaudeCodeAdapter extends ServiceMap.Service< - ClaudeCodeAdapter, - ClaudeCodeAdapterShape ->()("t3/provider/Services/ClaudeCodeAdapter") {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index be6f8b61eb..f153c2bf07 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -121,7 +121,7 @@ export const PROVIDER_CAPABILITIES_BY_PROVIDER: Readonly< supportsAttachments: true, persistentRuntime: true, }, - claudeCode: { + claudeAgent: { sessionModelSwitch: "in-session", transport: "sdk-query", modelDiscovery: "session-native", 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/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts index 5cb57402ab..37169ac76e 100644 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ b/apps/server/src/provider/claude-agent-sdk.d.ts @@ -17,14 +17,17 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly message?: string; }; + export interface CanUseToolCallbackOptions { + readonly signal: AbortSignal; + readonly toolUseID?: string; + readonly suggestions?: ReadonlyArray; + readonly [key: string]: unknown; + } + export type CanUseTool = ( toolName: string, toolInput: Record, - callbackOptions: { - readonly signal?: AbortSignal; - readonly suggestions?: ReadonlyArray; - readonly [key: string]: unknown; - }, + callbackOptions: CanUseToolCallbackOptions, ) => Promise; export interface SDKUserMessage { @@ -50,6 +53,7 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly web_search_requests?: number; }; }; + readonly modelUsage?: { readonly [key: string]: unknown }; readonly result?: string; readonly session_id?: string; readonly [key: string]: unknown; @@ -79,6 +83,39 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly preceding_tool_use_ids?: ReadonlyArray; readonly is_error?: boolean; readonly suggestions?: ReadonlyArray; + + // System message fields + readonly status?: string; + readonly hook_id?: string; + readonly hook_name?: string; + readonly hook_event?: string; + readonly output?: string; + readonly stdout?: string; + readonly stderr?: string; + readonly outcome?: "error" | "cancelled" | "success"; + readonly exit_code?: number; + + // Task fields + readonly task_id?: string; + readonly description?: string; + readonly task_type?: string; + readonly summary?: string; + readonly usage?: { readonly [key: string]: unknown }; + readonly last_tool_name?: string; + + // File persistence fields + readonly files?: ReadonlyArray<{ readonly filename: string; readonly file_id: string }>; + readonly failed?: ReadonlyArray<{ readonly filename: string; readonly error: string }>; + + // Tool progress fields + readonly elapsed_time_seconds?: number; + + // Auth status fields + readonly isAuthenticating?: boolean; + + // Stream event fields + readonly event?: Record; + readonly [key: string]: unknown; } @@ -89,6 +126,24 @@ declare module "@anthropic-ai/claude-agent-sdk" { export type EffortLevel = "low" | "medium" | "high" | "max"; + export interface SpawnOptions { + readonly args: string[]; + readonly env?: Record; + readonly cwd?: string; + readonly [key: string]: unknown; + } + + export interface SpawnedProcess { + readonly stdin: NodeJS.WritableStream; + readonly stdout: NodeJS.ReadableStream; + killed: boolean; + exitCode: number | null; + kill(signal: NodeJS.Signals): boolean; + on(event: "exit" | "error", listener: (...args: unknown[]) => void): void; + once(event: "exit" | "error", listener: (...args: unknown[]) => void): void; + off(event: "exit" | "error", listener: (...args: unknown[]) => void): void; + } + export interface Options { readonly cwd?: string; readonly model?: string; @@ -102,6 +157,10 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly resume?: string; readonly resumeSessionAt?: string; readonly includePartialMessages?: boolean; + readonly persistSession?: boolean; + readonly sessionId?: string; + readonly settings?: Record; + readonly spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess; readonly canUseTool?: CanUseTool; readonly env?: Record; readonly additionalDirectories?: ReadonlyArray; @@ -114,6 +173,7 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly setPermissionMode?: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens?: (maxThinkingTokens: number | null) => Promise; readonly close?: () => void; + readonly initializationResult?: () => Promise>; }; export function query(input: { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 08457d5bf4..3b489c1d6e 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,7 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; -import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; @@ -69,7 +69,7 @@ export function makeServerProviderLayer(): Layer.Layer< const copilotAdapterLayer = makeCopilotAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); - const claudeAdapterLayer = makeClaudeCodeAdapterLive( + const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); const cursorAdapterLayer = makeCursorAdapterLive( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 99c1daaf2b..e5554606d3 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -77,7 +77,6 @@ import { fetchOpenCodeModels } from "./opencodeServerManager.ts"; import { fetchCopilotModels, fetchCopilotUsage } from "./provider/Layers/CopilotAdapter.ts"; import { fetchCursorModels } from "./provider/Layers/CursorAdapter.ts"; import { fetchCursorUsage } from "./provider/Layers/CursorUsage.ts"; -import { fetchClaudeCodeUsage } from "./provider/Layers/ClaudeCodeAdapter.ts"; import { fetchCodexUsage } from "./provider/Layers/CodexAdapter.ts"; import { @@ -984,8 +983,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); return usage satisfies ProviderUsageResult; } - if (provider === "claudeCode") { - return fetchClaudeCodeUsage() satisfies ProviderUsageResult; + if (provider === "claudeAgent") { + return { provider } satisfies ProviderUsageResult; } if (provider === "geminiCli") { return fetchGeminiCliUsage() satisfies ProviderUsageResult; diff --git a/apps/web/package.json b/apps/web/package.json index e12f4902bd..19d7d8b6fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.5", + "version": "0.0.13", "private": true, "type": "module", "scripts": { diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f12..8fa9dca80e 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' +const PACKAGE_VERSION = '2.12.11' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 2085b727aa..ab05a221f0 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,8 @@ +import { Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + AppSettingsSchema, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, getAppSettingsSnapshot, @@ -79,8 +81,8 @@ describe("normalizeCustomModelSlugs", () => { }); it("normalizes provider-specific aliases for claude and cursor", () => { - expect(normalizeCustomModelSlugs(["sonnet"], "claudeCode")).toEqual([]); - expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeCode")).toEqual([ + expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); + expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ "claude/custom-sonnet", ]); expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]); @@ -116,7 +118,7 @@ describe("getAppModelOptions", () => { }); it("keeps a saved custom provider model available as an exact slug option", () => { - const options = getAppModelOptions("claudeCode", ["claude/custom-opus"], "claude/custom-opus"); + const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( true, @@ -169,7 +171,7 @@ describe("timestamp format defaults", () => { }); it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeCode", ["claude/custom-opus"]); + const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]); expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); @@ -203,3 +205,35 @@ describe("getAppSettingsSnapshot", () => { expect(getAppSettingsSnapshot().providerLogoAppearance).toBe("grayscale"); }); }); + +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.decodeUnknownSync(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 c1d4200a7b..a2ca6a2cf3 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, @@ -7,6 +7,7 @@ import { import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; 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; @@ -34,10 +35,11 @@ const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale" export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), - claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), + claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), geminiCli: new Set(getModelOptions("geminiCli").map((option) => option.slug)), @@ -47,7 +49,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record const PROVIDER_KINDS = [ "codex", "copilot", - "claudeCode", + "claudeAgent", "cursor", "opencode", "geminiCli", @@ -55,96 +57,69 @@ const PROVIDER_KINDS = [ "kilo", ] as const satisfies readonly ProviderKind[]; -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("")), - ), - copilotCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).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(() => "")), + copilotCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + copilotConfigDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( - Schema.withConstructorDefault(() => Option.some("local")), + withDefaults(() => "local" as const), ), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), - enableAssistantStreaming: Schema.Boolean.pipe( - Schema.withConstructorDefault(() => Option.some(false)), - ), - showCommandOutput: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), - showFileChangeDiffs: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), + enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + showCommandOutput: Schema.Boolean.pipe(withDefaults(() => true)), + showFileChangeDiffs: Schema.Boolean.pipe(withDefaults(() => true)), 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([])), - ), - customCopilotModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customClaudeModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customCursorModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customOpencodeModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customGeminiCliModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customAmpModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), - customKiloModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), + withDefaults(() => DEFAULT_TIMESTAMP_FORMAT), ), + customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customCopilotModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customCursorModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customOpencodeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customGeminiCliModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customAmpModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customKiloModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), gitTextGenerationModelByProvider: Schema.Record(Schema.String, Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some({} as Record)), + withDefaults(() => ({}) as Record), ), providerLogoAppearance: AppProviderLogoAppearanceSchema.pipe( - Schema.withConstructorDefault(() => Option.some("original")), - ), - grayscaleProviderLogos: Schema.Boolean.pipe( - Schema.withConstructorDefault(() => Option.some(false)), + withDefaults(() => "original" as const), ), + grayscaleProviderLogos: Schema.Boolean.pipe(withDefaults(() => false)), accentColor: Schema.String.check(Schema.isMaxLength(16)).pipe( - Schema.withConstructorDefault(() => Option.some(DEFAULT_ACCENT_COLOR)), + withDefaults(() => DEFAULT_ACCENT_COLOR), ), providerAccentColors: Schema.Record(Schema.String, Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some({} as Record)), + withDefaults(() => ({}) as Record), ), customAccentPresets: Schema.Array( Schema.Struct({ label: Schema.String.check(Schema.isMaxLength(64)), value: Schema.String.check(Schema.isMaxLength(16)), }), - ).pipe( - Schema.withConstructorDefault(() => - Option.some([] as ReadonlyArray<{ label: string; value: string }>), - ), - ), - backgroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - foregroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - uiFont: Schema.String.check(Schema.isMaxLength(256)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - codeFont: Schema.String.check(Schema.isMaxLength(256)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - uiFontSize: Schema.Number.pipe(Schema.withConstructorDefault(() => Option.some(0))), - codeFontSize: Schema.Number.pipe(Schema.withConstructorDefault(() => Option.some(0))), - contrast: Schema.Number.pipe(Schema.withConstructorDefault(() => Option.some(0))), - translucency: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))), + ).pipe(withDefaults(() => [] as ReadonlyArray<{ label: string; value: string }>)), + backgroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe(withDefaults(() => "")), + foregroundColorOverride: Schema.String.check(Schema.isMaxLength(16)).pipe(withDefaults(() => "")), + uiFont: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), + codeFont: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), + uiFontSize: Schema.Number.pipe(withDefaults(() => 0)), + codeFontSize: Schema.Number.pipe(withDefaults(() => 0)), + contrast: Schema.Number.pipe(withDefaults(() => 0)), + translucency: Schema.Boolean.pipe(withDefaults(() => false)), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -216,7 +191,7 @@ export function getCustomModelsForProvider( switch (provider) { case "copilot": return settings.customCopilotModels; - case "claudeCode": + case "claudeAgent": return settings.customClaudeModels; case "cursor": return settings.customCursorModels; @@ -238,7 +213,7 @@ export function patchCustomModels(provider: ProviderKind, models: string[]): Par switch (provider) { case "copilot": return { customCopilotModels: models }; - case "claudeCode": + case "claudeAgent": return { customClaudeModels: models }; case "cursor": return { customCursorModels: models }; @@ -283,7 +258,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customCopilotModels: normalizeCustomModelSlugs(settings.customCopilotModels, "copilot"), - customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), customOpencodeModels: normalizeCustomModelSlugs(settings.customOpencodeModels, "opencode"), customGeminiCliModels: normalizeCustomModelSlugs(settings.customGeminiCliModels, "geminiCli"), @@ -426,6 +401,18 @@ function migratePersistedAppSettings(value: unknown): unknown { settings.providerLogoAppearance = "grayscale"; } + // Migrate legacy "claudeCode" key to "claudeAgent" in record-typed settings + for (const key of ["gitTextGenerationModelByProvider", "providerAccentColors"] as const) { + const record = settings[key]; + if (record && typeof record === "object" && !Array.isArray(record)) { + const obj = record as Record; + if ("claudeCode" in obj && !("claudeAgent" in obj)) { + const { claudeCode, ...rest } = obj; + settings[key] = { ...rest, claudeAgent: claudeCode }; + } + } + } + return settings; } @@ -466,12 +453,28 @@ export function useAppSettings() { AppSettingsSchema, ); + // Apply legacy key migration that the schema decode path doesn't handle + // Migrate legacy "claudeCode" keys to "claudeAgent" in record-typed settings + // (e.g. gitTextGenerationModelByProvider.claudeCode, providerAccentColors.claudeCode). + const migratedSettings = useMemo(() => { + let patched = settings; + for (const key of ["gitTextGenerationModelByProvider", "providerAccentColors"] as const) { + const val = patched[key]; + if (val && typeof val === "object" && "claudeCode" in val) { + const record = { ...val } as Record; + if (typeof record.claudeAgent !== "string" && typeof record.claudeCode === "string") { + record.claudeAgent = record.claudeCode; + } + delete record.claudeCode; + patched = { ...patched, [key]: record }; + } + } + return patched; + }, [settings]); + const updateSettings = useCallback( (patch: Partial) => { - setSettings((prev) => ({ - ...prev, - ...patch, - })); + setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); }, [setSettings], ); @@ -481,7 +484,7 @@ export function useAppSettings() { }, [setSettings]); return { - settings, + settings: migratedSettings, updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, 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.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7b31dbdf38..6cbef09bd6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -368,6 +368,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { id: "plan-browser-test", turnId: null, planMarkdown, + implementedAt: null, + implementationThreadId: null, createdAt: isoAt(1_000), updatedAt: isoAt(1_001), }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dbf352a715..b509bf33dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,33 +7,39 @@ import { type CodexReasoningEffort, type CursorReasoningOption, 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, OrchestrationThreadActivity, - RuntimeMode, ProviderInteractionMode, + RuntimeMode, } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, getClaudeCodeEffortOptions, getDefaultClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, getCursorModelCapabilities, getReasoningEffortOptions, + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, normalizeModelSlug, parseCursorModelSelection, resolveCursorModelFromSelection, + resolveReasoningEffortForProvider, resolveModelSlugForProvider, + supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -62,8 +68,11 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, + findSidebarProposedPlan, findLatestProposedPlan, PROVIDER_OPTIONS, + deriveWorkLogEntries, + hasActionableProposedPlan, hasToolActivityForTurn, hasToolActivitySince, isLatestTurnSettled, @@ -159,8 +168,8 @@ import { } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; -import { ClaudeCodeTraitsPicker } from "./chat/ClaudeCodeTraitsPicker"; +import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; +import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CursorTraitsPicker } from "./chat/CursorTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; @@ -197,6 +206,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; @@ -271,11 +291,7 @@ 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 setComposerDraftClaudeCodeEffort = useComposerDraftStore( - (store) => store.setClaudeCodeEffort, - ); + const setComposerDraftModelOptions = useComposerDraftStore((store) => store.setModelOptions); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -603,7 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) { () => ({ codex: settings.customCodexModels, copilot: settings.customCopilotModels, - claudeCode: settings.customClaudeModels, + claudeAgent: settings.customClaudeModels, cursor: settings.customCursorModels, opencode: settings.customOpencodeModels, geminiCli: settings.customGeminiCliModels, @@ -637,35 +653,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 claudeCodeEffortOptions = getClaudeCodeEffortOptions(selectedProvider); - const supportsClaudeCodeEffort = claudeCodeEffortOptions.length > 0; - const selectedClaudeCodeEffort = - composerDraft.claudeCodeEffort ?? getDefaultClaudeCodeEffort(selectedProvider); + 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") { - const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), - ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); + return codexOptions ? { codex: codexOptions } : undefined; } - if (selectedProvider === "claudeCode" && supportsClaudeCodeEffort && selectedClaudeCodeEffort) { - return { claudeCode: { effort: selectedClaudeCodeEffort } }; + if (selectedProvider === "claudeAgent") { + const claudeOptions = normalizeClaudeModelOptions( + selectedModel, + draftModelOptions?.claudeAgent, + ); + return claudeOptions ? { claudeAgent: claudeOptions } : undefined; } return undefined; - }, [ - selectedClaudeCodeEffort, - selectedCodexFastModeEnabled, - selectedEffort, - selectedProvider, - supportsClaudeCodeEffort, - supportsReasoningEffort, - ]); + }, [draftModelOptions, selectedModel, selectedProvider]); const selectedCursorModel = useMemo( () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), [selectedModel, selectedProvider], @@ -832,6 +866,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], @@ -840,7 +884,7 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && - activeProposedPlan !== null; + hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = @@ -1675,7 +1719,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; } @@ -1684,7 +1728,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return !open; }); - }, [activePlan?.turnId, activeProposedPlan?.turnId]); + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2486,7 +2530,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - await handleInteractionModeChange(standaloneSlashCommand); + handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -2539,6 +2583,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, @@ -2561,7 +2610,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: messageTextForSend, + text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2712,7 +2761,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, @@ -2955,6 +3004,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"); @@ -2964,7 +3018,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, @@ -2992,7 +3046,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed, + text: outgoingMessageText, attachments: [], }, provider: selectedProvider, @@ -3004,6 +3058,14 @@ export default function ChatView({ threadId }: ChatViewProps) { assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), createdAt: messageCreatedAt, }); // Optimistically open the plan sidebar when implementing (not refining). @@ -3028,6 +3090,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, + activeProposedPlan, beginSendPhase, forceStickToBottom, isConnecting, @@ -3036,6 +3099,7 @@ export default function ChatView({ threadId }: ChatViewProps) { persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -3065,6 +3129,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 || @@ -3101,7 +3170,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: implementationPrompt, + text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, @@ -3159,6 +3228,7 @@ export default function ChatView({ threadId }: ChatViewProps) { navigate, resetSendPhase, runtimeMode, + selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, @@ -3190,12 +3260,21 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider, ], ); - const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { - setComposerDraftEffort(threadId, effort); + 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, setComposerDraftEffort, threadId], + [scheduleComposerFocus, setPrompt], ); const onCursorReasoningSelect = useCallback( (reasoning: CursorReasoningOption) => { @@ -3239,20 +3318,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [onProviderModelSelect, selectedModel, selectedProvider], ); - const onCodexFastModeChange = useCallback( - (enabled: boolean) => { - setComposerDraftCodexFastMode(threadId, enabled); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], - ); - const onClaudeCodeEffortSelect = useCallback( - (effort: ClaudeCodeEffort) => { - setComposerDraftClaudeCodeEffort(threadId, effort); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setComposerDraftClaudeCodeEffort, threadId], - ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3686,74 +3751,71 @@ export default function ChatView({ threadId }: ChatViewProps) { data-chat-composer-form="true" >
- {interactionMode === "plan" && ( - - PLAN - - )} - {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 ? ( - <> - {selectedProvider === "cursor" && - hasSelectedCursorTraits && - selectedCursorModel && - selectedCursorModelCapabilities && ( - - )} - {selectedProvider === "claudeCode" && - supportsClaudeCodeEffort && - selectedClaudeCodeEffort != null && ( - - )} +
+ {/* Provider/model picker */} + + + {isComposerFooterCompact ? ( + ) : selectedProvider === "claudeAgent" ? ( + + ) : null + } onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onToggleRuntimeMode={toggleRuntimeMode} /> - - ) : ( - <> - {selectedProvider === "cursor" ? ( - <> - {hasSelectedCursorTraits && ( + ) : ( + <> + {selectedProvider === "cursor" ? ( + <> + {hasSelectedCursorTraits && ( + + )} + {selectedCursorModel && + selectedCursorModelCapabilities && + hasSelectedCursorTraits && ( + + )} + + ) : selectedProvider === "codex" ? ( + <> - )} + + + ) : selectedProvider === "claudeAgent" ? ( + <> + + + + ) : null} - {selectedCursorModel && - selectedCursorModelCapabilities && - hasSelectedCursorTraits && ( - - )} - - ) : selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : selectedProvider === "claudeCode" && - supportsClaudeCodeEffort && - selectedClaudeCodeEffort != null ? ( - <> - - - - ) : null} - - + - - - + - - - {activePlan || activeProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
+ - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( + + {activePlan || 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} +
-
- )} + )} +
@@ -4273,14 +4306,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/ProviderLogo.tsx b/apps/web/src/components/ProviderLogo.tsx index ec6d63dd4b..a3bb15259c 100644 --- a/apps/web/src/components/ProviderLogo.tsx +++ b/apps/web/src/components/ProviderLogo.tsx @@ -18,7 +18,7 @@ import { const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, copilot: GitHubIcon, - claudeCode: ClaudeAI, + claudeAgent: ClaudeAI, cursor: CursorIcon, opencode: OpenCodeIcon, geminiCli: Gemini, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a8f84d564a..8c3b470105 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -141,6 +141,8 @@ describe("resolveThreadStatusPill", () => { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:05:00.000Z", planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, }, ], session: { @@ -155,6 +157,35 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Plan Ready", pulse: false }); }); + it("does not show plan ready after the proposed plan was implemented elsewhere", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + latestTurn: makeLatestTurn(), + proposedPlans: [ + { + id: "plan-1" as never, + turnId: "turn-1" as never, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + planMarkdown: "# Plan", + implementedAt: "2026-03-09T10:06:00.000Z", + implementationThreadId: "thread-implement" as never, + }, + ], + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Completed", pulse: false }); + }); + it("shows completed when there is an unseen completion and no active blocker", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d48873f7a6..d9b394e4dd 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,10 @@ import type { Thread } from "../types"; import { cn } from "../lib/utils"; -import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; +import { + findLatestProposedPlan, + hasActionableProposedPlan, + isLatestTurnSettled, +} from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export type SidebarNewThreadEnvMode = "local" | "worktree"; @@ -124,7 +128,9 @@ export function resolveThreadStatusPill(input: { !hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; + hasActionableProposedPlan( + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), + ); if (hasPlanReadyPrompt) { return { label: "Plan Ready", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c241b82ff2..963a7f0529 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,7 +45,13 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveThreadProvider } from "../lib/threadProvider"; -import { formatRelativeTime, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { + formatRelativeTime, + isLinuxPlatform, + isMacPlatform, + newCommandId, + newProjectId, +} from "../lib/utils"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; @@ -516,7 +522,7 @@ const USAGE_PROVIDERS: ReadonlyArray<{ provider: ProviderKind; label: string }> { provider: "copilot", label: "Copilot" }, { provider: "codex", label: "Codex" }, { provider: "cursor", label: "Cursor" }, - { provider: "claudeCode", label: "Claude Code" }, + { provider: "claudeAgent", label: "Claude Code" }, { provider: "geminiCli", label: "Gemini" }, { provider: "amp", label: "Amp" }, ]; @@ -547,7 +553,7 @@ function ProviderUsageSection() { const copilotUsage = useProviderUsage("copilot"); const codexUsage = useProviderUsage("codex"); const cursorUsage = useProviderUsage("cursor"); - const claudeUsage = useProviderUsage("claudeCode"); + const claudeUsage = useProviderUsage("claudeAgent"); const geminiUsage = useProviderUsage("geminiCli"); const ampUsage = useProviderUsage("amp"); @@ -555,7 +561,7 @@ function ProviderUsageSection() { copilot: copilotUsage.data, codex: codexUsage.data, cursor: cursorUsage.data, - claudeCode: claudeUsage.data, + claudeAgent: claudeUsage.data, geminiCli: geminiUsage.data, amp: ampUsage.data, }; @@ -617,7 +623,7 @@ function ProviderUsageSection() { } // Session usage (no quota) — show token/cost summary if ( - provider !== "claudeCode" && + provider !== "claudeAgent" && data?.sessionUsage && (data.sessionUsage.totalTokens || data.sessionUsage.totalCostUsd) ) { @@ -734,7 +740,8 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const shouldBrowseForProjectImmediately = isElectron; + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), diff --git a/apps/web/src/components/chat/ClaudeCodeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeCodeTraitsPicker.tsx deleted file mode 100644 index 41f917294a..0000000000 --- a/apps/web/src/components/chat/ClaudeCodeTraitsPicker.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { type ClaudeCodeEffort } from "@t3tools/contracts"; -import { getDefaultClaudeCodeEffort } 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, MenuTrigger } from "../ui/menu"; - -export const CLAUDE_CODE_EFFORT_LABEL: Record = { - low: "Low", - medium: "Medium", - high: "High", - max: "Max", -}; - -export const ClaudeCodeTraitsPicker = memo(function ClaudeCodeTraitsPicker(props: { - effort: ClaudeCodeEffort; - options: ReadonlyArray; - onEffortChange: (effort: ClaudeCodeEffort) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultEffort = getDefaultClaudeCodeEffort("claudeCode"); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {CLAUDE_CODE_EFFORT_LABEL[props.effort]} - - - -
Effort
- { - if (!value) return; - const nextEffort = props.options.find((option) => option === value); - if (!nextEffort) return; - props.onEffortChange(nextEffort); - }} - > - {props.options.map((effort) => ( - - {CLAUDE_CODE_EFFORT_LABEL[effort]} - {effort === defaultEffort ? " (default)" : ""} - - ))} - -
-
-
- ); -}); 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 6c72f497ba..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,25 +24,99 @@ import { MenuTrigger, } from "../ui/menu"; -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { +const CODEX_REASONING_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; + +function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): { effort: CodexReasoningEffort; fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); +} { const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - 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 triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), - ] + + return ( + <> + +
Reasoning
+ { + if (!value) return; + const nextEffort = options.find((option) => option === value); + if (!nextEffort) return; + setCodexModelOptions( + normalizeCodexModelOptions({ + ...modelOptions, + reasoningEffort: nextEffort, + }), + ); + }} + > + {options.map((option) => ( + + {CODEX_REASONING_LABELS[option]} + {option === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + setCodexModelOptions( + normalizeCodexModelOptions({ + ...modelOptions, + fastMode: value === "on", + }), + ); + }} + > + off + on + +
+ + ); +} + +export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); + +export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex; + const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); + const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) .join(" · "); @@ -47,46 +132,17 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {