diff --git a/.plans/17-claude-code.md b/.plans/17-claude-code.md new file mode 100644 index 0000000000..822e978f50 --- /dev/null +++ b/.plans/17-claude-code.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/ClaudeCodeAdapter.ts` +2. `apps/server/src/provider/Layers/ClaudeCodeAdapter.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` -> `ClaudeCodeAdapter` +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 `ClaudeCodeAdapter.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/18-cursor-agent-provider.md b/.plans/18-cursor-agent-provider.md new file mode 100644 index 0000000000..452592e68d --- /dev/null +++ b/.plans/18-cursor-agent-provider.md @@ -0,0 +1,327 @@ +# Plan: Cursor ACP (`agent acp`) Provider Integration + +## Goal + +Add Cursor as a first-class provider in T3 Code using ACP (`agent acp`) over JSON-RPC 2.0 stdio, with robust session lifecycle handling and canonical `ProviderRuntimeEvent` projection. + +--- + +## 1) Exploration Findings (from live ACP probes) + +### 1.1 Core invocation and transport + +1. Binary is `agent` on PATH (`2026.02.27-e7d2ef6` observed). +2. ACP server command is `agent acp`. +3. Transport is newline-delimited JSON-RPC 2.0 over stdio. +4. Messages: + - client -> server: requests and responses to server-initiated requests + - server -> client: responses, notifications (`session/update`), and server requests (`session/request_permission`) + +### 1.2 Handshake and session calls observed + +1. `initialize` returns: + - `protocolVersion` + - `agentCapabilities` (`loadSession`, `mcpCapabilities`, `promptCapabilities`) + - `authMethods` (includes `cursor_login`) +2. `authenticate { methodId: "cursor_login" }` returns `{}` when logged in. +3. `session/new` returns: + - `sessionId` + - `modes` (`agent`, `plan`, `ask`) +4. `session/load` works and requires `sessionId`, `cwd`, `mcpServers`. +5. `session/prompt` returns terminal response `{ stopReason: "end_turn" | "cancelled" }`. + +Important sequence note: +1. ACP currently allows `session/new` even without explicit `initialize`/`authenticate` when local auth already exists. +2. For adapter consistency and forward compatibility, we should still send `initialize` and `authenticate` during startup. + +### 1.3 `session/update` event families observed + +Observed `params.update.sessionUpdate` values: + +1. `available_commands_update` +2. `agent_thought_chunk` +3. `agent_message_chunk` +4. `tool_call` +5. `tool_call_update` + +Observed payload behavior: + +1. `agent_*_chunk` provides `content: { type: "text", text: string }`. +2. `tool_call` may be emitted multiple times for same `toolCallId`: + - initial generic form (`title: "Terminal"`, `rawInput: {}`) + - enriched form (`title: "\`pwd\`"`, `rawInput: { command: "pwd" }`) +3. `tool_call_update` statuses observed: + - `in_progress` + - `completed` +4. `tool_call_update` on completion may include `rawOutput`: + - terminal: `{ exitCode, stdout, stderr }` + - search/find: `{ totalFiles, truncated }` + +### 1.4 Permission flow observed + +1. ACP server sends `session/request_permission` (JSON-RPC request with `id`). +2. Request shape includes: + - `params.sessionId` + - `params.toolCall` + - `params.options` (`allow-once`, `allow-always`, `reject-once`) +3. Client must respond on same `id` with: + - `{ outcome: { outcome: "selected", optionId: "" } }` +4. Reject path still results in tool lifecycle completion events (`tool_call_update status: completed`), typically without `rawOutput`. + +### 1.5 Error and capability quirks + +1. `session/cancel` currently returns: + - JSON-RPC error `-32601` Method not found +2. Error shape examples: + - unknown auth method: `-32602` + - `session/load` missing/invalid params: `-32602` + - `session/prompt` unknown session: `-32603` with details +3. Parallel prompts on same session are effectively single-flight: + - second prompt can cause first to complete with `stopReason: "cancelled"`. +4. `session/new` accepts a `model` field (no explicit echo in response). + +Probe artifacts: +1. `.tmp/acp-probe/*/transcript.ndjson` +2. `.tmp/acp-probe/*/summary.json` +3. `scripts/cursor-acp-probe.mjs` + +--- + +## 2) Integration Constraints for T3 + +1. T3 adapter contract still requires: + - `startSession`, `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, `rollbackThread`, `stopSession`, `listSessions`, `hasSession`, `stopAll`, `streamEvents`. +2. Orchestration consumes canonical `ProviderRuntimeEvent` only. +3. `ProviderCommandReactor` provider precedence fix remains required (respect explicit provider on turn start). +4. ACP now supports external permission decisions, so Cursor can participate in T3 approval UX via adapter-managed request/response plumbing. + +--- + +## 3) Proposed Architecture + +### 3.1 New server components + +1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag + ACP event schemas). +2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns ACP process lifecycle, JSON-RPC routing, runtime projection). +3. No manager indirection; keep logic in layer implementation. + +### 3.2 Session model + +1. One long-lived ACP child process per T3 Cursor provider session. +2. Track: + - `providerSessionId` (T3 synthetic ID) + - `acpSessionId` (from `session/new` or restored via `session/load`) + - `cwd`, `model`, in-flight turn state + - pending permission requests by JSON-RPC request id +3. Resume support: + - persist `acpSessionId` in provider resume metadata and call `session/load` on reattach. + +### 3.3 Command strategy + +1. `startSession`: + - spawn `agent acp` + - `initialize` + - `authenticate(cursor_login)` (best-effort, typed failure handling) + - `session/new` or `session/load` +2. `sendTurn`: + - send `session/prompt { sessionId, prompt: [...] }` + - consume streaming `session/update` notifications until terminal prompt response +3. `interruptTurn`: + - no native `session/cancel` today; implement fallback: + - terminate ACP process + restart + `session/load` for subsequent turns + - mark in-flight turn as interrupted/failed in canonical events +4. `respondToRequest`: + - map T3 approval decision -> ACP `optionId` + - reply to exact JSON-RPC request id from `session/request_permission` + +### 3.4 Effect-first implementation style (required) + +1. Keep logic inside `CursorAdapterLive`. +2. Use Effect primitives: + - `Queue` + `Stream.fromQueue` for event fan-out + - `Ref` / `Ref.Synchronized` for session/process/request state + - scoped fibers for stdout/stderr read loops +3. Typed JSON decode at boundary: + - request/response envelopes + - `session/update` union schema + - permission-request schema +4. Keep adapter errors in typed error algebra with explicit mapping at process/protocol boundaries. + +--- + +## 4) Canonical Event Mapping Plan (ACP -> ProviderRuntimeEvent) + +1. `session/update: agent_message_chunk` + - emit `message.delta` for assistant stream +2. prompt terminal response (`session/prompt` result `stopReason: end_turn`) + - emit `message.completed` + `turn.completed` +3. `session/update: agent_thought_chunk` + - initial mapping: emit thinking activity (or ignore if we keep current canonical surface minimal) +4. `session/update: tool_call` + - first-seen `toolCallId` emits `tool.started` + - subsequent `tool_call` for same ID treated as metadata update (no duplicate started event) +5. `session/update: tool_call_update` + - `in_progress`: optional progress activity + - `completed`: emit `tool.completed` with summarized `rawOutput` when present +6. `session/request_permission` + - emit `approval.requested` with mapped options + - when client decision sent, emit `approval.resolved` +7. protocol/process error + - emit `runtime.error` + - fail active turn/session as appropriate + +Synthetic IDs: +1. `turnId`: T3-generated UUID per `sendTurn`. +2. `itemId`: + - assistant stream: `${turnId}:assistant` + - tools: `${turnId}:${toolCallId}` + +--- + +## 5) Approval, Resume, and Rollback Behavior + +### 5.1 Approvals + +1. Cursor ACP permission requests are externally controllable; implement full `respondToRequest` path in v1. +2. Decision mapping: + - allow once -> `allow-once` + - allow always -> `allow-always` + - reject -> `reject-once` + +### 5.2 Resume + +1. `session/load` is available and should be first-class for adapter restart/reconnect. +2. Must send required params: `sessionId`, `cwd`, `mcpServers`. + +### 5.3 Rollback / thread read + +1. ACP currently has no observed rollback API. +2. Plan for v1: + - `readThread`: adapter-maintained snapshot projection + - `rollbackThread`: explicit unsupported error +3. Product guard: + - disable checkpoint revert for Cursor threads in UI until rollback exists. + +--- + +## 6) Required Contract and Runtime Changes + +### 6.1 Contracts + +1. Add `cursor` to `ProviderKind`. +2. Add Cursor provider start options (`providerOptions.cursor`), ACP-oriented: + - optional `binaryPath` + - optional auth/mode knobs if needed later +3. Extend model options for Cursor list and traits mapping. +4. Add schemas for ACP-native event union in Cursor adapter service file. + +### 6.2 Server orchestration and registry + +1. Register `CursorAdapter` in provider registry and server layer wiring. +2. Update provider-kind persistence decoding for `cursor`. +3. Fix `ProviderCommandReactor` precedence to honor explicit provider in turn-start command. + +### 6.3 Web + +1. Cursor in provider picker and model picker (already partially done). +2. Trait controls map to concrete Cursor model identifiers. +3. Surface unsupported rollback behavior in UX. + +--- + +## 7) Implementation Phases + +### Phase A: ACP process and protocol skeleton + +1. Implement ACP process lifecycle in `CursorAdapterLive`. +2. Implement JSON-RPC request/response multiplexer. +3. Implement `initialize`/`authenticate`/`session/new|load` flow. +4. Wire `streamEvents` from ACP notifications. + +### Phase B: Runtime projection and approvals + +1. Map `session/update` variants to canonical runtime events. +2. Implement permission-request bridging to `respondToRequest`. +3. Implement dedupe for repeated `tool_call` on same `toolCallId`. + +### Phase C: Turn control and interruption + +1. Implement single in-flight prompt protection per session. +2. Implement interruption fallback (process restart + reload) because `session/cancel` unavailable. +3. Ensure clean state recovery on ACP process crash. + +### Phase D: Orchestration + UX polish + +1. Provider routing precedence fix. +2. Cursor-specific UX notes for unsupported rollback. +3. End-to-end smoke and event log validation. + +--- + +## 8) Test Plan + +Follow project rule: backend external-service integrations tested via layered fakes, not by mocking core business logic. + +### 8.1 Unit tests (`CursorAdapter`) + +1. JSON-RPC envelope parsing: + - response matching by id + - server request handling (`session/request_permission`) + - notification decode (`session/update`) +2. Event projection: + - `agent_message_chunk` / `agent_thought_chunk` + - `tool_call` + `tool_call_update` dedupe/lifecycle + - permission request -> approval events +3. Error mapping: + - unknown session + - method-not-found (`session/cancel`) + - invalid params + +### 8.2 Provider service/routing tests + +1. Registry resolves `cursor`. +2. Session directory persistence reads/writes `cursor`. +3. ProviderService fan-out ordering with Cursor ACP events. + +### 8.3 Orchestration tests + +1. `thread.turn.start` with `provider: cursor` routes to Cursor adapter. +2. approval response command maps to ACP permission response. +3. checkpoint revert on Cursor thread returns controlled unsupported failure. + +### 8.4 Optional live smoke + +1. Env-gated ACP smoke: + - start session + - run prompt + - observe deltas + completion + - exercise permission request path with one tool call + +--- + +## 9) Operational Notes + +1. Keep one in-flight turn per ACP session. +2. Keep per-session ACP process logs/NDJSON artifacts for debugging. +3. Treat `session/cancel` as unsupported until Cursor ships it; avoid relying on it. +4. Preserve resume metadata (`acpSessionId`) for crash recovery. + +--- + +## 10) Open Questions + +1. Should we call `authenticate` always, or only after auth-required errors? +2. Should model selection be passed at `session/new` only, or can/should we support model switching mid-session if ACP adds API? +3. For interruption UX, do we expose “hard interrupt” semantics (process restart) explicitly? + +--- + +## 11) Delivery Checklist + +1. Plan/documentation switched from headless `agent -p` to ACP `agent acp`. +2. Contracts updated (`ProviderKind`, Cursor options, model/trait mapping). +3. Cursor ACP adapter layer implemented and registered. +4. Provider precedence fixed in orchestration router. +5. Approval response path wired through ACP permission requests. +6. Tests added for protocol decode, projection, approval flow, and routing. +7. Lint + tests green. diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index 8853248b24..f616b79ec4 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,15 +1,22 @@ -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { readEnvFromLoginShell } from "@t3tools/shared/shell"; +/** + * On macOS, GUI apps (including Electron) do not inherit the user's + * login-shell environment. Restore it by sourcing the shell profile + * and capturing the full set of exported variables. + */ export function fixPath(): void { if (process.platform !== "darwin") return; try { const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; + const env = readEnvFromLoginShell(shell); + if (env) { + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } } } catch { - // Keep inherited PATH if shell lookup fails. + // Keep inherited environment if shell lookup fails. } } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b6ae7ee982..a634e8882c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -40,7 +40,6 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService. import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; @@ -153,7 +152,7 @@ export interface OrchestrationIntegrationHarness { readonly rootDir: string; readonly workspaceDir: string; readonly dbPath: string; - readonly adapterHarness: TestProviderAdapterHarness | null; + readonly adapterHarness: TestProviderAdapterHarness; readonly engine: OrchestrationEngineShape; readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; readonly providerService: ProviderService["Service"]; @@ -189,7 +188,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; readonly realCodex?: boolean; } @@ -253,12 +252,10 @@ export const makeOrchestrationIntegrationHarness = ( ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), - Layer.provide(AnalyticsService.layerTest), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), - Layer.provide(AnalyticsService.layerTest), ); const runtimeServicesLayer = Layer.mergeAll( @@ -406,14 +403,19 @@ export const makeOrchestrationIntegrationHarness = ( disposed = true; const shutdown = Effect.gen(function* () { + const stopAllExit = yield* Effect.exit( + Effect.promise(() => runtime.runPromise(providerService.stopAll())), + ); const closeScopeExit = yield* Effect.exit(Scope.close(scope, Exit.void)); const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); - const failureCause = Exit.isFailure(closeScopeExit) - ? closeScopeExit.cause - : Exit.isFailure(disposeRuntimeExit) - ? disposeRuntimeExit.cause - : null; + const failureCause = Exit.isFailure(stopAllExit) + ? stopAllExit.cause + : Exit.isFailure(closeScopeExit) + ? closeScopeExit.cause + : Exit.isFailure(disposeRuntimeExit) + ? disposeRuntimeExit.cause + : null; if (failureCause) { return yield* Effect.failCause(failureCause); @@ -433,7 +435,7 @@ export const makeOrchestrationIntegrationHarness = ( rootDir, workspaceDir, dbPath, - adapterHarness, + adapterHarness: adapterHarness as TestProviderAdapterHarness, engine, snapshotQuery, providerService, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 82c08da3e9..25ce8773bd 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -35,7 +35,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; } function nowIso(): string { @@ -206,7 +206,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -216,7 +216,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 3b0a3a4002..606087f0ac 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -32,7 +32,7 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = "codex"; +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -180,7 +180,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + ], }; - yield* harness.adapterHarness!.queueTurnResponseForNextSession(turnResponse); + yield* harness.adapterHarness.queueTurnResponseForNextSession(turnResponse); yield* startTurn({ harness, commandId: "cmd-turn-start-single", @@ -314,7 +314,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => Effect.gen(function* () { yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + yield* harness.adapterHarness.queueTurnResponseForNextSession({ events: [ { type: "turn.started", @@ -373,7 +373,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", ); - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", @@ -473,7 +473,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons Effect.gen(function* () { yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + yield* harness.adapterHarness.queueTurnResponseForNextSession({ events: [ { type: "turn.started", @@ -538,7 +538,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons assert.equal(resolvedRow.decision, "accept"); const approvalResponses = yield* waitForSync( - () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), (responses) => responses.length === 1, "provider approval response", ); @@ -554,7 +554,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () = Effect.gen(function* () { yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + yield* harness.adapterHarness.queueTurnResponseForNextSession({ events: [ { type: "turn.started", @@ -633,7 +633,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git Effect.gen(function* () { yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + yield* harness.adapterHarness.queueTurnResponseForNextSession({ events: [ { type: "turn.started", @@ -691,7 +691,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, ); - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", @@ -796,7 +796,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, ); - assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ threadId: THREAD_ID, @@ -842,3 +842,416 @@ it.live( }), ), ); + +it.live("starts a claudeCode session on first turn when provider is requested", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live("recovers claudeCode sessions after provider stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.providerService.stopAll(); + yield* waitForSync( + () => harness.adapterHarness.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + }); + yield* waitForSync( + () => harness.adapterHarness.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1", + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested"); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live("reverts claudeCode turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeCode", + ), +); diff --git a/apps/server/package.json b/apps/server/package.json index d1fea10b6e..b1c20af0b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,6 +22,7 @@ "test": "vitest run" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/server/scripts/logger-scope-repro.ts b/apps/server/scripts/logger-scope-repro.ts new file mode 100644 index 0000000000..52f6fc1e93 --- /dev/null +++ b/apps/server/scripts/logger-scope-repro.ts @@ -0,0 +1,66 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import path from "node:path"; + +import { Effect, FileSystem, Layer, Logger, ServiceMap } from "effect"; + +import { makeEventNdjsonLogger } from "../src/provider/Layers/EventNdjsonLogger.ts"; + +class LogDir extends ServiceMap.Service()("t3/scripts/logger-scope-repro/LogDir") {} + +const main = Effect.gen(function* () { + const logdir = yield* LogDir; + const providerLogPath = path.join(logdir, "provider"); + + yield* Effect.logInfo(`providerLogPath=${providerLogPath}`); + + const providerLogger = yield* makeEventNdjsonLogger(providerLogPath, { + stream: "native", + batchWindowMs: 10, + }); + + yield* Effect.logInfo("before provider write"); + + if (providerLogger) { + yield* providerLogger.write( + { + kind: "probe", + message: "provider-only event", + }, + "thread-123" as never, + ); + } + + yield* Effect.logInfo("after provider write"); + yield* Effect.sleep("50 millis"); + + if (providerLogger) { + yield* providerLogger.close(); + } + yield* Effect.logInfo("after provider close"); + + const fs = yield* FileSystem.FileSystem; + const logContents = yield* fs + .readDirectory(logdir, { recursive: true }) + .pipe( + Effect.flatMap((entries) => + Effect.all(entries.map((entry) => fs.readFileString(path.join(logdir, entry)))), + ), + ); + yield* Effect.logInfo(`logContents=${logContents}`); +}); + +Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const logdir = path.join(process.cwd(), "logtest"); + yield* fs.makeDirectory(logdir); + + const fileLogger = yield* Logger.formatSimple.pipe( + Logger.toFile(path.join(logdir, "global.log")), + ); + const dualLogger = Logger.layer([fileLogger, Logger.consolePretty()]); + + const mainLayer = Layer.mergeAll(dualLogger, Layer.succeed(LogDir, logdir)); + + yield* main.pipe(Effect.provide(mainLayer)); +}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/apps/server/src/cliEnvironment.ts b/apps/server/src/cliEnvironment.ts new file mode 100644 index 0000000000..2c3a5fee61 --- /dev/null +++ b/apps/server/src/cliEnvironment.ts @@ -0,0 +1,136 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { isCommandAvailable } from "./open"; + +export interface CliLaunchSpec { + readonly command: string; + readonly argsPrefix: ReadonlyArray; +} + +function splitPathEntries(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): ReadonlyArray { + const rawPath = env.PATH ?? env.Path ?? env.path ?? ""; + if (rawPath.length === 0) { + return []; + } + + return rawPath + .split(platform === "win32" ? ";" : ":") + .map((entry) => entry.trim().replace(/^"+|"+$/g, "")) + .filter((entry) => entry.length > 0); +} + +function resolveGeminiDistFromDirectory(directory: string): string | null { + const candidate = path.join(directory, "node_modules", "@google", "gemini-cli", "dist", "index.js"); + return existsSync(candidate) ? candidate : null; +} + +function resolveGeminiDistFromPath(env: NodeJS.ProcessEnv): string | null { + if (process.platform !== "win32") { + return null; + } + + for (const entry of splitPathEntries(env, process.platform)) { + const candidate = resolveGeminiDistFromDirectory(entry); + if (candidate) { + return candidate; + } + } + + return null; +} + +function resolveWindowsGeminiDist(env: NodeJS.ProcessEnv): string | null { + const appDataCandidate = env.APPDATA + ? path.join(env.APPDATA, "npm", "node_modules", "@google", "gemini-cli", "dist", "index.js") + : null; + if (appDataCandidate && existsSync(appDataCandidate)) { + return appDataCandidate; + } + + return resolveGeminiDistFromPath(env); +} + +function resolveGeminiPackageRootFromDist(distPath: string): string | null { + const packageRoot = path.dirname(path.dirname(distPath)); + return existsSync(path.join(packageRoot, "package.json")) ? packageRoot : null; +} + +export function buildPopupSafeEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return { + ...baseEnv, + NO_BROWSER: baseEnv.NO_BROWSER ?? "true", + BROWSER: baseEnv.BROWSER ?? "none", + ...overrides, + }; +} + +export function buildNonInteractiveGitEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return buildPopupSafeEnv(baseEnv, { + GIT_TERMINAL_PROMPT: "0", + GCM_INTERACTIVE: "never", + GCM_MODAL_PROMPT: "false", + GIT_ASKPASS: "echo", + SSH_ASKPASS: "echo", + ...overrides, + }); +} + +export function resolveGeminiCliLaunchSpec(env: NodeJS.ProcessEnv = process.env): CliLaunchSpec | null { + if (process.platform === "win32") { + const distPath = resolveWindowsGeminiDist(env); + if (!distPath) { + return null; + } + return { + command: "node", + argsPrefix: [distPath], + }; + } + + if (!isCommandAvailable("gemini", { env, platform: process.platform })) { + return null; + } + + return { + command: "gemini", + argsPrefix: [], + }; +} + +export function resolveGeminiAcpModulePath(env: NodeJS.ProcessEnv = process.env): string | null { + const launch = resolveGeminiCliLaunchSpec(env); + const distPath = launch?.argsPrefix[0]; + if (!distPath || !existsSync(distPath)) { + return null; + } + + const packageRoot = resolveGeminiPackageRootFromDist(distPath); + if (!packageRoot) { + return null; + } + + const candidate = path.join( + packageRoot, + "node_modules", + "@agentclientprotocol", + "sdk", + "dist", + "acp.js", + ); + return existsSync(candidate) ? candidate : null; +} + +export function isCodexCliAvailable(env: NodeJS.ProcessEnv = process.env): boolean { + return isCommandAvailable("codex", { env, platform: process.platform }); +} + +export function isGeminiCliAvailable(env: NodeJS.ProcessEnv = process.env): boolean { + return resolveGeminiCliLaunchSpec(env) !== null; +} diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 4e0073d75e..6032a1e3cf 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -7,14 +7,11 @@ import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { buildCodexInitializeParams, - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, - readCodexAccountSnapshot, - resolveCodexModelForAccount, } from "./codexAppServerManager"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -23,20 +20,14 @@ function createSendTurnHarness() { const manager = new CodexAppServerManager(); const context = { session: { + sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, - account: { - type: "unknown", - planType: null, - sparkEnabled: true, - }, }; const requireSession = vi @@ -66,11 +57,10 @@ function createThreadControlHarness() { const manager = new CodexAppServerManager(); const context = { session: { + sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", @@ -98,11 +88,10 @@ function createPendingUserInputHarness() { const manager = new CodexAppServerManager(); const context = { session: { + sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", - runtimeMode: "full-access", - model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", @@ -205,70 +194,6 @@ describe("isRecoverableThreadResumeError", () => { }); }); -describe("readCodexAccountSnapshot", () => { - it("disables spark for chatgpt plus accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "plus@example.com", - planType: "plus", - }), - ).toEqual({ - type: "chatgpt", - planType: "plus", - sparkEnabled: false, - }); - }); - - it("keeps spark enabled for chatgpt pro accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "chatgpt", - email: "pro@example.com", - planType: "pro", - }), - ).toEqual({ - type: "chatgpt", - planType: "pro", - sparkEnabled: true, - }); - }); - - it("keeps spark enabled for api key accounts", () => { - expect( - readCodexAccountSnapshot({ - type: "apiKey", - }), - ).toEqual({ - type: "apiKey", - planType: null, - sparkEnabled: true, - }); - }); -}); - -describe("resolveCodexModelForAccount", () => { - it("falls back from spark to default for unsupported chatgpt plans", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "chatgpt", - planType: "plus", - sparkEnabled: false, - }), - ).toBe("gpt-5.3-codex"); - }); - - it("keeps spark for supported plans", () => { - expect( - resolveCodexModelForAccount("gpt-5.3-codex-spark", { - type: "chatgpt", - planType: "pro", - sparkEnabled: true, - }), - ).toBe("gpt-5.3-codex-spark"); - }); -}); - describe("startSession", () => { it("enables Codex experimental api capabilities during initialize", () => { expect(buildCodexInitializeParams()).toEqual({ @@ -316,59 +241,6 @@ describe("startSession", () => { manager.stopAll(); } }); - - it("fails fast with an upgrade message when codex is below the minimum supported version", async () => { - const manager = new CodexAppServerManager(); - const events: Array<{ method: string; kind: string; message?: string }> = []; - manager.on("event", (event) => { - events.push({ - method: event.method, - kind: event.kind, - ...(event.message ? { message: event.message } : {}), - }); - }); - - const versionCheck = vi - .spyOn( - manager as unknown as { - assertSupportedCodexCliVersion: (input: { - binaryPath: string; - cwd: string; - homePath?: string; - }) => void; - }, - "assertSupportedCodexCliVersion", - ) - .mockImplementation(() => { - throw new Error( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }); - - try { - await expect( - manager.startSession({ - threadId: asThreadId("thread-1"), - provider: "codex", - runtimeMode: "full-access", - }), - ).rejects.toThrow( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - expect(versionCheck).toHaveBeenCalledTimes(1); - expect(events).toEqual([ - { - method: "session/startFailed", - kind: "error", - message: - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - }, - ]); - } finally { - versionCheck.mockRestore(); - manager.stopAll(); - } - }); }); describe("sendTurn", () => { @@ -386,8 +258,8 @@ describe("sendTurn", () => { }, ], model: "gpt-5.3", - serviceTier: "fast", effort: "high", + serviceTier: "fast", }); expect(result).toEqual({ @@ -410,8 +282,8 @@ describe("sendTurn", () => { }, ], model: "gpt-5.3-codex", - serviceTier: "fast", effort: "high", + serviceTier: "fast", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", @@ -441,7 +313,6 @@ describe("sendTurn", () => { url: "data:image/png;base64,BBBB", }, ], - model: "gpt-5.3-codex", }); }); @@ -475,67 +346,6 @@ describe("sendTurn", () => { }); }); - it("passes Codex default mode as a collaboration preset on turn/start", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "PLEASE IMPLEMENT THIS PLAN:\n- step 1", - interactionMode: "default", - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "PLEASE IMPLEMENT THIS PLAN:\n- step 1", - text_elements: [], - }, - ], - model: "gpt-5.3-codex", - collaborationMode: { - mode: "default", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - }, - }, - }); - }); - - it("keeps the session model when interaction mode is set without an explicit model", async () => { - const { manager, context, sendRequest } = createSendTurnHarness(); - context.session.model = "gpt-5.2-codex"; - - await manager.sendTurn({ - threadId: asThreadId("thread_1"), - input: "Plan this with my current session model", - interactionMode: "plan", - }); - - expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { - threadId: "thread_1", - input: [ - { - type: "text", - text: "Plan this with my current session model", - text_elements: [], - }, - ], - model: "gpt-5.2-codex", - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.2-codex", - reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, - }, - }, - }); - }); - it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); @@ -644,8 +454,8 @@ describe("respondToUserInput", () => { asThreadId("thread_1"), ApprovalRequestId.makeUnsafe("req-user-input-1"), { - scope: "All request methods", - compat: "Keep current envelope", + scope: "All request methods", + compat: "Keep current envelope", }, ); @@ -672,80 +482,6 @@ describe("respondToUserInput", () => { }), ); }); - - it("preserves explicit empty multi-select answers", async () => { - const { manager, context, requireSession, writeMessage, emitEvent } = - createPendingUserInputHarness(); - - await manager.respondToUserInput( - asThreadId("thread_1"), - ApprovalRequestId.makeUnsafe("req-user-input-1"), - { - scope: [], - }, - ); - - expect(requireSession).toHaveBeenCalledWith("thread_1"); - expect(writeMessage).toHaveBeenCalledWith(context, { - id: 42, - result: { - answers: { - scope: { answers: [] }, - }, - }, - }); - expect(emitEvent).toHaveBeenCalledWith( - expect.objectContaining({ - method: "item/tool/requestUserInput/answered", - payload: { - requestId: "req-user-input-1", - answers: { - scope: { answers: [] }, - }, - }, - }), - ); - }); - - it("tracks file-read approval requests with the correct method", () => { - const manager = new CodexAppServerManager(); - const context = { - session: { - sessionId: "sess_1", - provider: "codex", - status: "ready", - threadId: asThreadId("thread_1"), - resumeCursor: { threadId: "thread_1" }, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - }; - type ApprovalRequestContext = { - session: typeof context.session; - pendingApprovals: typeof context.pendingApprovals; - pendingUserInputs: typeof context.pendingUserInputs; - }; - - ( - manager as unknown as { - handleServerRequest: ( - context: ApprovalRequestContext, - request: Record, - ) => void; - } - ).handleServerRequest(context, { - jsonrpc: "2.0", - id: 42, - method: "item/fileRead/requestApproval", - params: {}, - }); - - const request = Array.from(context.pendingApprovals.values())[0]; - expect(request?.requestKind).toBe("file-read"); - expect(request?.method).toBe("item/fileRead/requestApproval"); - }); }); describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce4607..041ee8d3c0 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -22,11 +22,7 @@ import { import { normalizeModelSlug } from "@t3tools/shared/model"; import { Effect, ServiceMap } from "effect"; -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "./provider/codexCliVersion"; +import { buildPopupSafeEnv } from "./cliEnvironment"; type PendingRequestKey = string; @@ -40,10 +36,7 @@ interface PendingRequest { interface PendingApprovalRequest { requestId: ApprovalRequestId; jsonRpcId: string | number; - method: - | "item/commandExecution/requestApproval" - | "item/fileChange/requestApproval" - | "item/fileRead/requestApproval"; + method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; threadId: ThreadId; turnId?: TurnId; @@ -64,7 +57,6 @@ interface CodexUserInputAnswer { interface CodexSessionContext { session: ProviderSession; - account: CodexAccountSnapshot; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; @@ -96,30 +88,13 @@ interface JsonRpcNotification { params?: unknown; } -type CodexPlanType = - | "free" - | "go" - | "plus" - | "pro" - | "team" - | "business" - | "enterprise" - | "edu" - | "unknown"; - -interface CodexAccountSnapshot { - readonly type: "apiKey" | "chatgpt" | "unknown"; - readonly planType: CodexPlanType | null; - readonly sparkEnabled: boolean; -} - export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; - readonly serviceTier?: string | null; readonly effort?: string; + readonly serviceTier?: string; readonly interactionMode?: ProviderInteractionMode; } @@ -144,8 +119,6 @@ export interface CodexThreadSnapshot { turns: CodexThreadTurnSnapshot[]; } -const CODEX_VERSION_CHECK_TIMEOUT_MS = 4_000; - const ANSI_ESCAPE_CHAR = String.fromCharCode(27); const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); const CODEX_STDERR_LOG_REGEX = @@ -161,50 +134,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; -const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; -const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; -const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { - const record = asObject(response); - const account = asObject(record?.account) ?? record; - const accountType = asString(account?.type); - - if (accountType === "apiKey") { - return { - type: "apiKey", - planType: null, - sparkEnabled: true, - }; - } - - if (accountType === "chatgpt") { - const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; - return { - type: "chatgpt", - planType, - sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), - }; - } - - return { - type: "unknown", - planType: null, - sparkEnabled: true, - }; -} - export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -327,19 +256,6 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. `; -export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default - -You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. - -Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. - -## request_user_input availability - -The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. - -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -`; - function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { readonly approvalPolicy: "on-request" | "never"; readonly sandbox: "workspace-write" | "danger-full-access"; @@ -357,17 +273,6 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { }; } -export function resolveCodexModelForAccount( - model: string | undefined, - account: CodexAccountSnapshot, -): string | undefined { - if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { - return model; - } - - return CODEX_DEFAULT_MODEL; -} - /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the @@ -420,7 +325,7 @@ function buildCodexCollaborationMode(input: { readonly effort?: string; }): | { - mode: "default" | "plan"; + mode: "plan"; settings: { model: string; reasoning_effort: string; @@ -428,19 +333,16 @@ function buildCodexCollaborationMode(input: { }; } | undefined { - if (input.interactionMode === undefined) { + if (input.interactionMode !== "plan") { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex"; return { - mode: input.interactionMode, + mode: "plan", settings: { model, reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, }, }; } @@ -452,14 +354,18 @@ function toCodexUserInputAnswer(value: unknown): CodexUserInputAnswer { if (Array.isArray(value)) { const answers = value.filter((entry): entry is string => typeof entry === "string"); - return { answers }; + if (answers.length > 0) { + return { answers }; + } } if (value && typeof value === "object") { const maybeAnswers = (value as { answers?: unknown }).answers; if (Array.isArray(maybeAnswers)) { const answers = maybeAnswers.filter((entry): entry is string => typeof entry === "string"); - return { answers }; + if (answers.length > 0) { + return { answers }; + } } } @@ -543,17 +449,9 @@ export class CodexAppServerManager extends EventEmitter; model?: string; - serviceTier?: string | null; effort?: string; + serviceTier?: string; collaborationMode?: { - mode: "default" | "plan"; + mode: "plan"; settings: { model: string; reasoning_effort: string; @@ -783,19 +655,16 @@ export class CodexAppServerManager extends EventEmitter): void { context.session = { ...context.session, @@ -1521,51 +1380,6 @@ function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { }; } -function assertSupportedCodexCliVersion(input: { - readonly binaryPath: string; - readonly cwd: string; - readonly homePath?: string; -}): void { - const result = spawnSync(input.binaryPath, ["--version"], { - cwd: input.cwd, - env: { - ...process.env, - ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), - }, - encoding: "utf8", - shell: process.platform === "win32", - stdio: ["ignore", "pipe", "pipe"], - timeout: CODEX_VERSION_CHECK_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - }); - - if (result.error) { - const lower = result.error.message.toLowerCase(); - if ( - lower.includes("enoent") || - lower.includes("command not found") || - lower.includes("not found") - ) { - throw new Error(`Codex CLI (${input.binaryPath}) is not installed or not executable.`); - } - throw new Error( - `Failed to execute Codex CLI version check: ${result.error.message || String(result.error)}`, - ); - } - - const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? ""; - if (result.status !== 0) { - const detail = stderr.trim() || stdout.trim() || `Command exited with code ${result.status}.`; - throw new Error(`Codex CLI version check failed. ${detail}`); - } - - const parsedVersion = parseCodexCliVersion(`${stdout}\n${stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - throw new Error(formatCodexCliUpgradeMessage(parsedVersion)); - } -} - function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { return undefined; diff --git a/apps/server/src/geminiCliManager.test.ts b/apps/server/src/geminiCliManager.test.ts new file mode 100644 index 0000000000..37bdcb0416 --- /dev/null +++ b/apps/server/src/geminiCliManager.test.ts @@ -0,0 +1,378 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GeminiCliManager } from "./geminiCliManager"; + +function promptText( + prompt: ReadonlyArray<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>, +): string { + return prompt + .filter((entry): entry is { type: "text"; text: string } => entry.type === "text") + .map((entry) => entry.text) + .join("\n"); +} + +function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const startedAt = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (predicate()) { + resolve(); + return; + } + if (Date.now() - startedAt > timeoutMs) { + reject(new Error("Timed out waiting for condition.")); + return; + } + setTimeout(tick, 10); + }; + tick(); + }); +} + +describe("GeminiCliManager", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reuses a warm ACP runtime for follow-up turns on the same thread", async () => { + const createdRuntimes: string[] = []; + const promptCalls: string[] = []; + const events: Array> = []; + + const manager = new GeminiCliManager({ + prewarmSessions: false, + runtimeFactory: async (model, handlers) => { + createdRuntimes.push(model); + return { + model, + initialize: async () => undefined, + newSession: vi.fn(async () => ({ + sessionId: `session-${model}`, + modes: { currentModeId: "default" }, + })), + loadSession: vi.fn(async (sessionId: string) => ({ + sessionId, + modes: { currentModeId: "default" }, + })), + setSessionMode: vi.fn(async () => undefined), + prompt: vi.fn(async (sessionId: string, prompt) => { + const text = promptText(prompt); + promptCalls.push(`${sessionId}:${text}`); + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `reply:${text}` }, + }, + }); + return { stopReason: "end_turn" }; + }), + cancel: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + }; + }, + }); + + manager.on("event", (event: Record) => { + events.push(event); + }); + + manager.startSession({ + threadId: "thread-1", + model: "gemini-3-flash-preview", + cwd: process.cwd(), + }); + + manager.sendTurn({ + threadId: "thread-1", + text: "first", + approvalMode: "yolo", + }); + + await waitFor(() => promptCalls.length === 1); + + manager.sendTurn({ + threadId: "thread-1", + text: "second", + approvalMode: "yolo", + }); + + await waitFor(() => promptCalls.length === 2); + + expect(createdRuntimes).toEqual(["gemini-3-flash-preview"]); + expect(promptCalls).toEqual([ + "session-gemini-3-flash-preview:first", + "session-gemini-3-flash-preview:second", + ]); + + const configuredEvents = events.filter((event) => event.method === "session/configured"); + expect(configuredEvents).toHaveLength(1); + + const messageEvents = events.filter((event) => event.method === "gemini/message"); + expect(messageEvents).toHaveLength(2); + }); + + it("loads a persisted Gemini session without replaying hydrated history into live events", async () => { + const events: Array> = []; + const manager = new GeminiCliManager({ + prewarmSessions: false, + runtimeFactory: async (_model, handlers) => ({ + model: "gemini-2.5-pro", + initialize: async () => undefined, + newSession: vi.fn(async () => ({ sessionId: "new-session", modes: { currentModeId: "default" } })), + loadSession: vi.fn(async (sessionId: string) => { + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "old-history" }, + }, + }); + return { sessionId, modes: { currentModeId: "default" } }; + }), + setSessionMode: vi.fn(async () => undefined), + prompt: vi.fn(async (sessionId: string) => { + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "fresh-reply" }, + }, + }); + return { stopReason: "end_turn" }; + }), + cancel: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + }), + }); + + manager.on("event", (event: Record) => { + events.push(event); + }); + + manager.startSession({ + threadId: "thread-2", + model: "gemini-2.5-pro", + cwd: process.cwd(), + resumeCursor: { sessionId: "persisted-session" }, + }); + + manager.sendTurn({ + threadId: "thread-2", + text: "continue", + approvalMode: "yolo", + }); + + await waitFor( + () => events.some((event) => event.method === "gemini/message" && event.content === "fresh-reply"), + ); + + const messageContents = events + .filter((event) => event.method === "gemini/message") + .map((event) => event.content); + + expect(messageContents).toEqual(["fresh-reply"]); + }); + + it("emits thought, plan, and incremental tool update events during an ACP turn", async () => { + const events: Array> = []; + const manager = new GeminiCliManager({ + prewarmSessions: false, + runtimeFactory: async (_model, handlers) => ({ + model: "gemini-2.5-pro", + initialize: async () => undefined, + newSession: vi.fn(async () => ({ + sessionId: "session-live-events", + modes: { currentModeId: "default" }, + })), + loadSession: vi.fn(async (sessionId: string) => ({ + sessionId, + modes: { currentModeId: "default" }, + })), + setSessionMode: vi.fn(async () => undefined), + prompt: vi.fn(async (sessionId: string) => { + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Inspecting files" }, + }, + }); + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: [ + { content: "Inspect files", status: "completed", priority: "high" }, + { content: "Apply patch", status: "in_progress", priority: "high" }, + ], + }, + }); + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Edit app shell", + kind: "edit", + status: "pending", + locations: [{ path: "apps/web/src/components/ChatView.tsx", line: 10 }], + }, + }); + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + title: "Edit app shell", + kind: "edit", + status: "in_progress", + locations: [{ path: "apps/web/src/components/ChatView.tsx", line: 10 }], + content: [{ type: "diff", path: "apps/web/src/components/ChatView.tsx" }], + }, + }); + handlers.onSessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Done." }, + }, + }); + return { stopReason: "end_turn" }; + }), + cancel: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + }), + }); + + manager.on("event", (event: Record) => { + events.push(event); + }); + + manager.startSession({ + threadId: "thread-live-events", + model: "gemini-2.5-pro", + cwd: process.cwd(), + }); + + manager.sendTurn({ + threadId: "thread-live-events", + text: "fix the ui", + approvalMode: "yolo", + }); + + await waitFor(() => events.some((event) => event.method === "gemini/result")); + + expect(events.some((event) => event.method === "gemini/thought")).toBe(true); + expect(events.some((event) => event.method === "gemini/plan")).toBe(true); + expect( + events.some( + (event) => event.method === "gemini/tool_update" && event.status === "in_progress", + ), + ).toBe(true); + }); + + it("passes image prompt blocks through to the ACP runtime", async () => { + const seenPrompts: Array>> = []; + const manager = new GeminiCliManager({ + prewarmSessions: false, + runtimeFactory: async (_model, handlers) => ({ + model: "gemini-2.5-pro", + initialize: async () => undefined, + newSession: vi.fn(async () => ({ + sessionId: "session-image", + modes: { currentModeId: "default" }, + })), + loadSession: vi.fn(async (sessionId: string) => ({ + sessionId, + modes: { currentModeId: "default" }, + })), + setSessionMode: vi.fn(async () => undefined), + prompt: vi.fn(async (_sessionId: string, prompt) => { + seenPrompts.push(prompt as ReadonlyArray>); + handlers.onSessionUpdate({ + sessionId: "session-image", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "saw image" }, + }, + }); + return { stopReason: "end_turn" }; + }), + cancel: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + }), + }); + + manager.startSession({ + threadId: "thread-image", + model: "gemini-2.5-pro", + cwd: process.cwd(), + }); + + manager.sendTurn({ + threadId: "thread-image", + text: "describe this image", + prompt: [ + { type: "text", text: "describe this image" }, + { type: "image", data: "ZmFrZQ==", mimeType: "image/png" }, + ], + approvalMode: "yolo", + }); + + await waitFor(() => seenPrompts.length === 1); + expect(seenPrompts[0]?.some((entry) => entry.type === "image")).toBe(true); + }); + + it("preserves the text instruction when prompt attachments omit a text block", async () => { + const seenPrompts: Array>> = []; + const manager = new GeminiCliManager({ + prewarmSessions: false, + runtimeFactory: async (_model, handlers) => ({ + model: "gemini-2.5-pro", + initialize: async () => undefined, + newSession: vi.fn(async () => ({ + sessionId: "session-image-no-text", + modes: { currentModeId: "default" }, + })), + loadSession: vi.fn(async (sessionId: string) => ({ + sessionId, + modes: { currentModeId: "default" }, + })), + setSessionMode: vi.fn(async () => undefined), + prompt: vi.fn(async (_sessionId: string, prompt) => { + seenPrompts.push(prompt as ReadonlyArray>); + handlers.onSessionUpdate({ + sessionId: "session-image-no-text", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "processed prompt" }, + }, + }); + return { stopReason: "end_turn" }; + }), + cancel: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + }), + }); + + manager.startSession({ + threadId: "thread-image-no-text", + model: "gemini-2.5-pro", + cwd: process.cwd(), + }); + + manager.sendTurn({ + threadId: "thread-image-no-text", + text: "analyze the attached screenshot", + prompt: [{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" }], + approvalMode: "yolo", + }); + + await waitFor(() => seenPrompts.length === 1); + expect(seenPrompts[0]).toEqual([ + { type: "text", text: "analyze the attached screenshot" }, + { type: "image", data: "ZmFrZQ==", mimeType: "image/png" }, + ]); + }); +}); diff --git a/apps/server/src/geminiCliManager.ts b/apps/server/src/geminiCliManager.ts new file mode 100644 index 0000000000..966ac8ca1a --- /dev/null +++ b/apps/server/src/geminiCliManager.ts @@ -0,0 +1,1249 @@ +import { randomUUID } from "node:crypto"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import { Readable, Writable } from "node:stream"; +import { pathToFileURL } from "node:url"; + +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import { + buildPopupSafeEnv, + resolveGeminiAcpModulePath, + resolveGeminiCliLaunchSpec, +} from "./cliEnvironment"; + +export interface GeminiStreamEvent { + readonly type: "init" | "message" | "tool_use" | "tool_result" | "error" | "result"; + readonly [key: string]: unknown; +} + +type GeminiApprovalMode = "default" | "auto_edit" | "yolo" | "plan"; +type GeminiSessionModeId = "default" | "autoEdit" | "yolo" | "plan"; +type GeminiAcpPromptBlock = + | { readonly type: "text"; readonly text: string } + | { readonly type: "image"; readonly data: string; readonly mimeType: string; readonly uri?: string }; + +interface GeminiAcpMessageContent { + readonly type?: unknown; + readonly text?: unknown; + readonly path?: unknown; + readonly oldText?: unknown; + readonly newText?: unknown; + readonly terminalId?: unknown; +} + +interface GeminiAcpPlanEntry { + readonly content?: unknown; + readonly status?: unknown; + readonly priority?: unknown; +} + +interface GeminiAcpSessionUpdate { + readonly sessionUpdate: + | "user_message_chunk" + | "agent_message_chunk" + | "agent_thought_chunk" + | "tool_call" + | "tool_call_update" + | "plan" + | "available_commands_update" + | "current_mode_update" + | "config_option_update" + | "session_info_update" + | string; + readonly content?: + | GeminiAcpMessageContent + | ReadonlyArray>; + readonly toolCallId?: unknown; + readonly title?: unknown; + readonly kind?: unknown; + readonly status?: unknown; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly locations?: unknown; + readonly currentModeId?: unknown; + readonly modeId?: unknown; + readonly entries?: ReadonlyArray; + readonly updatedAt?: unknown; +} + +interface GeminiAcpNotification { + readonly sessionId: string; + readonly update: GeminiAcpSessionUpdate; +} + +interface GeminiAcpRequestPermissionOption { + readonly optionId?: unknown; + readonly kind?: unknown; +} + +interface GeminiAcpRequestPermissionParams { + readonly sessionId?: unknown; + readonly options?: ReadonlyArray; +} + +interface GeminiAcpPromptResult { + readonly stopReason?: unknown; +} + +interface GeminiAcpSessionResponse { + readonly sessionId?: unknown; + readonly modes?: { + readonly currentModeId?: unknown; + }; +} + +interface GeminiAcpConnection { + initialize(params: Record): Promise>; + newSession(params: { cwd: string; mcpServers: ReadonlyArray }): Promise; + loadSession(params: { + sessionId: string; + cwd: string; + mcpServers: ReadonlyArray; + }): Promise; + setSessionMode(params: { sessionId: string; modeId: GeminiSessionModeId }): Promise>; + prompt(params: { + sessionId: string; + prompt: ReadonlyArray; + }): Promise; + cancel(params: { sessionId: string }): Promise; +} + +interface GeminiAcpRuntime { + readonly model: string; + initialize(): Promise; + newSession(cwd: string): Promise; + loadSession(sessionId: string, cwd: string): Promise; + setSessionMode(sessionId: string, modeId: GeminiSessionModeId): Promise; + prompt(sessionId: string, prompt: ReadonlyArray): Promise; + cancel(sessionId: string): Promise; + close(): void; +} + +interface GeminiAcpRuntimeHandlers { + readonly onSessionUpdate: (notification: GeminiAcpNotification) => void; + readonly onRequestPermission: ( + params: GeminiAcpRequestPermissionParams, + ) => Promise<{ outcome: { outcome: string; optionId?: string } }>; + readonly onClose: (error?: Error) => void; +} + +type GeminiAcpRuntimeFactory = ( + model: string, + handlers: GeminiAcpRuntimeHandlers, +) => Promise; + +export interface GeminiCliManagerOptions { + readonly runtimeFactory?: GeminiAcpRuntimeFactory; + readonly prewarmSessions?: boolean; +} + +export interface GeminiSessionContext { + readonly sessionId: string; + readonly threadId: string; + model: string; + cwd: string; + geminiSessionId?: string; + status: "idle" | "running" | "stopped"; + activeTurnId: string | null; + activeProcess: ChildProcess | null; + currentMode: GeminiSessionModeId; + runtimeModel: string | null; + sessionSetupPromise: Promise | null; + hydrating: boolean; +} + +export interface GeminiSessionResumeCursor { + readonly sessionId: string; +} + +export interface GeminiStartSessionInput { + readonly threadId: string; + readonly model: string; + readonly cwd: string; + readonly resumeCursor?: GeminiSessionResumeCursor; +} + +export interface GeminiSendTurnInput { + readonly threadId: string; + readonly text: string; + readonly prompt?: ReadonlyArray; + readonly model?: string; + readonly cwd?: string; + readonly approvalMode?: GeminiApprovalMode; +} + +export interface GeminiTurnResult { + readonly turnId: string; + readonly threadId: string; + readonly resumeCursor?: GeminiSessionResumeCursor; +} + +export class GeminiCliManager extends EventEmitter { + private readonly sessions = new Map(); + private readonly threadIdByGeminiSessionId = new Map(); + private readonly runtimePromises = new Map>(); + private readonly runtimeFactory: GeminiAcpRuntimeFactory; + private readonly prewarmSessions: boolean; + private readonly acpModulePath: string | null; + private readonly forceAcp: boolean; + + constructor(options: GeminiCliManagerOptions = {}) { + super(); + this.runtimeFactory = options.runtimeFactory ?? createGeminiAcpRuntime; + this.prewarmSessions = options.prewarmSessions ?? true; + this.acpModulePath = resolveGeminiAcpModulePath(); + this.forceAcp = options.runtimeFactory !== undefined; + } + + startSession(input: GeminiStartSessionInput): GeminiSessionContext { + const context: GeminiSessionContext = { + sessionId: randomUUID(), + threadId: input.threadId, + model: normalizeModelSlug(input.model, "gemini") ?? input.model, + cwd: input.cwd, + status: "idle", + activeTurnId: null, + activeProcess: null, + currentMode: "default", + runtimeModel: null, + sessionSetupPromise: null, + hydrating: false, + ...(input.resumeCursor?.sessionId + ? { geminiSessionId: input.resumeCursor.sessionId } + : {}), + }; + + this.sessions.set(input.threadId, context); + if (context.geminiSessionId) { + this.threadIdByGeminiSessionId.set(context.geminiSessionId, context.threadId); + } + + this.emit("event", { + type: "session", + method: "session/started", + kind: "lifecycle", + threadId: input.threadId, + sessionId: context.sessionId, + provider: "gemini", + }); + + if (context.geminiSessionId) { + this.emit("event", { + type: "session", + method: "session/configured", + kind: "lifecycle", + threadId: input.threadId, + sessionId: context.sessionId, + provider: "gemini", + resumeCursor: { sessionId: context.geminiSessionId }, + }); + } + + if (this.prewarmSessions && this.canUseAcp()) { + void this.queueSessionSetup(context, async () => { + const runtime = await this.ensureAcpRuntime(context.model); + await this.prepareAcpSession(context, runtime, "yolo", { emitConfigured: false }); + }).catch(() => undefined); + } + + return context; + } + + sendTurn(input: GeminiSendTurnInput): GeminiTurnResult { + const context = this.sessions.get(input.threadId); + if (!context) { + throw new Error(`No Gemini session for thread: ${input.threadId}`); + } + if (context.status === "stopped") { + throw new Error(`Gemini session is stopped for thread: ${input.threadId}`); + } + + const trimmedText = input.text.trim(); + if (trimmedText.length === 0) { + throw new Error("Turn input must include text."); + } + if (context.status === "running") { + throw new Error(`Gemini turn already running for thread: ${input.threadId}`); + } + + context.cwd = input.cwd ?? context.cwd; + const nextModel = normalizeModelSlug(input.model ?? context.model, "gemini") ?? context.model; + if (nextModel !== context.model) { + this.clearSessionBinding(context); + context.model = nextModel; + } + + const turnId = `turn_${randomUUID().slice(0, 8)}`; + const approvalMode = input.approvalMode ?? "yolo"; + const desiredMode = toSessionModeId(approvalMode); + + context.activeTurnId = turnId; + context.status = "running"; + + this.emit("event", { + type: "session", + method: "session/connecting", + kind: "lifecycle", + threadId: input.threadId, + sessionId: context.sessionId, + provider: "gemini", + message: `Connecting to Gemini CLI for ${context.model}`, + }); + + this.emit("event", { + type: "turn", + method: "turn/started", + kind: "lifecycle", + threadId: input.threadId, + turnId, + provider: "gemini", + model: context.model, + }); + + if (this.canUseAcp()) { + void this.runTurnViaAcp(context, turnId, trimmedText, input.prompt, desiredMode); + } else { + this.runTurnViaLegacyCli(context, turnId, trimmedText, approvalMode); + } + + return { + turnId, + threadId: input.threadId, + ...(context.geminiSessionId ? { resumeCursor: { sessionId: context.geminiSessionId } } : {}), + }; + } + + interruptTurn(threadId: string): void { + const context = this.sessions.get(threadId); + if (!context) { + return; + } + + if (context.geminiSessionId && this.canUseAcp()) { + void this.ensureAcpRuntime(context.model) + .then((runtime) => runtime.cancel(context.geminiSessionId!)) + .catch(() => undefined); + return; + } + + if (context.activeProcess) { + killChildTree(context.activeProcess); + } + } + + stopSession(threadId: string): void { + const context = this.sessions.get(threadId); + if (!context) { + return; + } + + if (context.geminiSessionId && this.canUseAcp()) { + void this.ensureAcpRuntime(context.model) + .then((runtime) => runtime.cancel(context.geminiSessionId!)) + .catch(() => undefined); + } + + if (context.activeProcess) { + killChildTree(context.activeProcess); + } + + context.status = "stopped"; + context.activeProcess = null; + this.clearSessionBinding(context); + this.sessions.delete(threadId); + } + + listSessions(): GeminiSessionContext[] { + return Array.from(this.sessions.values()); + } + + hasSession(threadId: string): boolean { + return this.sessions.has(threadId); + } + + stopAll(): void { + for (const [threadId] of this.sessions) { + this.stopSession(threadId); + } + + for (const runtimePromise of this.runtimePromises.values()) { + void runtimePromise.then((runtime) => runtime.close()).catch(() => undefined); + } + this.runtimePromises.clear(); + } + + private canUseAcp(): boolean { + return this.forceAcp || this.acpModulePath !== null; + } + + private async runTurnViaAcp( + context: GeminiSessionContext, + turnId: string, + text: string, + prompt: ReadonlyArray | undefined, + desiredMode: GeminiSessionModeId, + ): Promise { + try { + const runtime = await this.ensureAcpRuntime(context.model); + await this.queueSessionSetup(context, async () => { + await this.prepareAcpSession(context, runtime, desiredMode, { emitConfigured: true }); + }); + + if (context.status === "stopped" || context.activeTurnId !== turnId || !context.geminiSessionId) { + return; + } + + this.emit("event", { + type: "session", + method: "session/ready", + kind: "lifecycle", + threadId: context.threadId, + sessionId: context.sessionId, + provider: "gemini", + ...(context.geminiSessionId + ? { resumeCursor: { sessionId: context.geminiSessionId } } + : {}), + message: "Gemini CLI is ready", + }); + + const result = await runtime.prompt( + context.geminiSessionId, + inputPromptBlocks(text, prompt), + ); + const stopReason = typeof result.stopReason === "string" ? result.stopReason : "end_turn"; + + this.emit("event", { + type: "result", + method: "gemini/result", + kind: stopReason === "cancelled" ? "error" : "data", + threadId: context.threadId, + turnId, + provider: "gemini", + status: stopReason === "cancelled" ? "error" : "completed", + ...(stopReason === "cancelled" + ? { error: { message: "Gemini turn cancelled." } } + : {}), + stopReason, + }); + + this.emit("event", { + type: "turn", + method: "turn/ended", + kind: "lifecycle", + threadId: context.threadId, + turnId, + provider: "gemini", + exitCode: 0, + }); + } catch (error) { + this.emit("event", { + type: "error", + method: "turn/error", + kind: "error", + threadId: context.threadId, + turnId, + provider: "gemini", + message: toErrorMessage(error, "Gemini ACP turn failed."), + }); + + this.emit("event", { + type: "turn", + method: "turn/ended", + kind: "lifecycle", + threadId: context.threadId, + turnId, + provider: "gemini", + exitCode: 1, + }); + } finally { + if (context.activeTurnId === turnId) { + context.activeTurnId = null; + context.status = "idle"; + } + } + } + + private runTurnViaLegacyCli( + context: GeminiSessionContext, + turnId: string, + text: string, + approvalMode: GeminiApprovalMode, + ): void { + const launch = resolveGeminiLaunch(); + const args = [ + ...launch.argsPrefix, + "--prompt", + text, + "--output-format", + "stream-json", + "--approval-mode", + approvalMode, + "--model", + context.model, + ]; + + if (approvalMode !== "plan") { + args.push("--sandbox", "false"); + } + + if (context.geminiSessionId) { + args.push("--resume", context.geminiSessionId); + } + + const child = spawn(launch.command, args, { + cwd: context.cwd, + env: buildPopupSafeEnv(), + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + context.activeProcess = child; + + const stdout = child.stdout; + if (!stdout) { + throw new Error("Gemini CLI stdout pipe is unavailable."); + } + + const rl: ReadlineInterface = createInterface({ input: stdout }); + let stderrBuffer = ""; + let readyEventEmitted = false; + + const emitReady = () => { + if (readyEventEmitted) { + return; + } + readyEventEmitted = true; + this.emit("event", { + type: "session", + method: "session/ready", + kind: "lifecycle", + threadId: context.threadId, + sessionId: context.sessionId, + provider: "gemini", + ...(context.geminiSessionId + ? { resumeCursor: { sessionId: context.geminiSessionId } } + : {}), + message: "Gemini CLI is ready", + }); + }; + + rl.on("line", (line: string) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + try { + const event = JSON.parse(trimmed) as GeminiStreamEvent & { + readonly session_id?: unknown; + }; + + if (event.type === "init" && typeof event.session_id === "string") { + this.updateGeminiSessionId(context, event.session_id, true); + emitReady(); + } + + this.emit("event", { + ...event, + method: `gemini/${event.type}`, + kind: event.type === "error" ? "error" : "data", + threadId: context.threadId, + turnId, + provider: "gemini", + }); + } catch { + emitReady(); + this.emit("event", { + type: "message", + method: "gemini/message", + kind: "data", + threadId: context.threadId, + turnId, + provider: "gemini", + role: "assistant", + content: trimmed, + delta: true, + }); + } + }); + + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrBuffer += chunk.toString(); + }); + + child.on("close", (code: number | null) => { + rl.close(); + context.status = "idle"; + context.activeTurnId = null; + context.activeProcess = null; + + const trimmedStderr = stderrBuffer.trim(); + if (code !== 0 && code !== null) { + this.emit("event", { + type: "error", + method: "turn/error", + kind: "error", + threadId: context.threadId, + turnId, + provider: "gemini", + exitCode: code, + message: trimmedStderr || `Gemini CLI exited with code ${code}.`, + }); + } + + this.emit("event", { + type: "turn", + method: "turn/ended", + kind: "lifecycle", + threadId: context.threadId, + turnId, + provider: "gemini", + exitCode: code ?? 0, + ...(trimmedStderr ? { stderr: trimmedStderr } : {}), + }); + }); + + child.on("error", (error: Error) => { + rl.close(); + context.status = "idle"; + context.activeTurnId = null; + context.activeProcess = null; + + this.emit("event", { + type: "error", + method: "turn/error", + kind: "error", + threadId: context.threadId, + turnId, + provider: "gemini", + message: error.message, + }); + }); + } + + private queueSessionSetup( + context: GeminiSessionContext, + operation: () => Promise, + ): Promise { + const previous = context.sessionSetupPromise ?? Promise.resolve(); + const next = previous.then(operation, operation); + let tracked: Promise; + tracked = next.finally(() => { + if (context.sessionSetupPromise === tracked) { + context.sessionSetupPromise = null; + } + }); + context.sessionSetupPromise = tracked; + return tracked; + } + + private async prepareAcpSession( + context: GeminiSessionContext, + runtime: GeminiAcpRuntime, + desiredMode: GeminiSessionModeId, + options: { emitConfigured: boolean }, + ): Promise { + const canReuseLoadedSession = + context.geminiSessionId !== undefined && context.runtimeModel === runtime.model; + + if (!canReuseLoadedSession && context.geminiSessionId) { + context.hydrating = true; + this.threadIdByGeminiSessionId.set(context.geminiSessionId, context.threadId); + try { + const response = await runtime.loadSession(context.geminiSessionId, context.cwd); + context.currentMode = asSessionModeId(response.modes?.currentModeId) ?? context.currentMode; + context.runtimeModel = runtime.model; + } catch { + this.clearSessionBinding(context); + } finally { + context.hydrating = false; + } + } + + if (!context.geminiSessionId) { + const response = await runtime.newSession(context.cwd); + const sessionId = response.sessionId; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new Error("Gemini ACP did not return a session id."); + } + + this.updateGeminiSessionId(context, sessionId, options.emitConfigured); + context.currentMode = asSessionModeId(response.modes?.currentModeId) ?? "default"; + context.runtimeModel = runtime.model; + } + + if (context.currentMode !== desiredMode) { + const sessionId = context.geminiSessionId; + if (!sessionId) { + throw new Error("Gemini ACP session is missing after preparation."); + } + await runtime.setSessionMode(sessionId, desiredMode); + context.currentMode = desiredMode; + } + } + + private updateGeminiSessionId( + context: GeminiSessionContext, + sessionId: string, + emitConfigured: boolean, + ): void { + if (context.geminiSessionId && context.geminiSessionId !== sessionId) { + this.threadIdByGeminiSessionId.delete(context.geminiSessionId); + } + context.geminiSessionId = sessionId; + this.threadIdByGeminiSessionId.set(sessionId, context.threadId); + + if (emitConfigured) { + this.emit("event", { + type: "session", + method: "session/configured", + kind: "lifecycle", + threadId: context.threadId, + sessionId: context.sessionId, + provider: "gemini", + resumeCursor: { sessionId }, + }); + } + } + + private clearSessionBinding(context: GeminiSessionContext): void { + if (context.geminiSessionId) { + this.threadIdByGeminiSessionId.delete(context.geminiSessionId); + } + delete context.geminiSessionId; + context.runtimeModel = null; + context.currentMode = "default"; + } + + private async ensureAcpRuntime(model: string): Promise { + const normalizedModel = normalizeModelSlug(model, "gemini") ?? model; + const existing = this.runtimePromises.get(normalizedModel); + if (existing) { + return existing; + } + + const promise = this.runtimeFactory(normalizedModel, { + onSessionUpdate: (notification) => { + this.handleAcpSessionUpdate(notification); + }, + onRequestPermission: async (params) => this.handleAcpPermissionRequest(params), + onClose: (error) => { + this.runtimePromises.delete(normalizedModel); + this.handleRuntimeClose(normalizedModel, error); + }, + }).catch((error) => { + this.runtimePromises.delete(normalizedModel); + throw error; + }); + + this.runtimePromises.set(normalizedModel, promise); + return promise; + } + + private handleAcpSessionUpdate(notification: GeminiAcpNotification): void { + const threadId = this.threadIdByGeminiSessionId.get(notification.sessionId); + if (!threadId) { + return; + } + const context = this.sessions.get(threadId); + if (!context || context.hydrating) { + return; + } + + const turnId = context.activeTurnId; + const update = notification.update; + if (!turnId) { + return; + } + + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = readContentText(update.content); + if (!text) { + return; + } + this.emit("event", { + type: "message", + method: "gemini/message", + kind: "data", + threadId, + turnId, + provider: "gemini", + role: "assistant", + content: text, + delta: true, + }); + return; + } + + case "agent_thought_chunk": { + const text = readContentText(update.content); + if (!text) { + return; + } + this.emit("event", { + type: "thought", + method: "gemini/thought", + kind: "data", + threadId, + turnId, + provider: "gemini", + content: text, + delta: true, + }); + return; + } + + case "tool_call": { + this.emit("event", { + type: "tool_use", + method: "gemini/tool_use", + kind: "data", + threadId, + turnId, + provider: "gemini", + tool_id: + typeof update.toolCallId === "string" && update.toolCallId.length > 0 + ? update.toolCallId + : undefined, + tool_name: typeof update.title === "string" ? update.title : "Gemini tool", + status: update.status, + tool_kind: update.kind, + parameters: update.rawInput, + locations: update.locations, + }); + return; + } + + case "tool_call_update": { + const normalizedStatus = normalizeToolCallStatus(update.status); + const toolName = typeof update.title === "string" ? update.title : "Gemini tool"; + const output = + flattenToolCallContent(update.content) ?? + stringifyUnknown(update.rawOutput) ?? + flattenContentBlocks(update.content); + + this.emit("event", { + type: "tool_update", + method: "gemini/tool_update", + kind: "data", + threadId, + turnId, + provider: "gemini", + tool_id: + typeof update.toolCallId === "string" && update.toolCallId.length > 0 + ? update.toolCallId + : undefined, + tool_name: toolName, + status: normalizedStatus, + output, + rawInput: update.rawInput, + rawOutput: update.rawOutput, + tool_kind: update.kind, + locations: update.locations, + }); + if (normalizedStatus === "completed" || normalizedStatus === "failed") { + this.emit("event", { + type: "tool_result", + method: "gemini/tool_result", + kind: normalizedStatus === "failed" ? "error" : "data", + threadId, + turnId, + provider: "gemini", + tool_id: + typeof update.toolCallId === "string" && update.toolCallId.length > 0 + ? update.toolCallId + : undefined, + tool_name: toolName, + status: normalizedStatus, + output, + rawOutput: update.rawOutput, + tool_kind: update.kind, + locations: update.locations, + }); + } + return; + } + + case "plan": { + this.emit("event", { + type: "plan", + method: "gemini/plan", + kind: "data", + threadId, + turnId, + provider: "gemini", + entries: Array.isArray(update.entries) ? update.entries : [], + }); + return; + } + + case "current_mode_update": { + context.currentMode = + asSessionModeId(update.currentModeId ?? update.modeId) ?? context.currentMode; + return; + } + + case "session_info_update": { + this.emit("event", { + type: "session_info", + method: "gemini/session_info", + kind: "data", + threadId, + turnId, + provider: "gemini", + title: typeof update.title === "string" ? update.title : undefined, + updatedAt: typeof update.updatedAt === "string" ? update.updatedAt : undefined, + }); + return; + } + + default: + return; + } + } + + private async handleAcpPermissionRequest( + params: GeminiAcpRequestPermissionParams, + ): Promise<{ outcome: { outcome: string; optionId?: string } }> { + const sessionId = typeof params.sessionId === "string" ? params.sessionId : undefined; + const threadId = sessionId ? this.threadIdByGeminiSessionId.get(sessionId) : undefined; + const context = threadId ? this.sessions.get(threadId) : undefined; + const options = Array.isArray(params.options) ? params.options : []; + + if (context?.currentMode === "plan") { + return { outcome: { outcome: "cancelled" } }; + } + + const allowOption = options.find((option) => { + const kind = typeof option.kind === "string" ? option.kind : ""; + return kind.includes("allow") || kind.includes("approve"); + }); + const selected = allowOption ?? options[0]; + const optionId = typeof selected?.optionId === "string" ? selected.optionId : undefined; + return optionId + ? { outcome: { outcome: "selected", optionId } } + : { outcome: { outcome: "cancelled" } }; + } + + private handleRuntimeClose(model: string, error?: Error): void { + const message = error?.message?.trim(); + for (const context of this.sessions.values()) { + if (context.runtimeModel !== model) { + continue; + } + + context.runtimeModel = null; + if (context.activeTurnId) { + this.emit("event", { + type: "error", + method: "turn/error", + kind: "error", + threadId: context.threadId, + turnId: context.activeTurnId, + provider: "gemini", + message: message || `Gemini runtime for ${model} exited unexpectedly.`, + }); + + this.emit("event", { + type: "turn", + method: "turn/ended", + kind: "lifecycle", + threadId: context.threadId, + turnId: context.activeTurnId, + provider: "gemini", + exitCode: 1, + ...(message ? { stderr: message } : {}), + }); + + context.activeTurnId = null; + context.status = "idle"; + } + } + } +} + +function resolveGeminiLaunch(): { command: string; argsPrefix: ReadonlyArray } { + const resolved = resolveGeminiCliLaunchSpec(); + if (!resolved) { + throw new Error( + "Gemini CLI is not installed or could not be resolved. Install `@google/gemini-cli` and ensure it is available.", + ); + } + + return resolved; +} + +async function createGeminiAcpRuntime( + model: string, + handlers: GeminiAcpRuntimeHandlers, +): Promise { + const launch = resolveGeminiLaunch(); + const acpModulePath = resolveGeminiAcpModulePath(); + if (!acpModulePath || !existsSync(acpModulePath)) { + throw new Error("Gemini ACP runtime is unavailable on this installation."); + } + + const child = spawn( + launch.command, + [...launch.argsPrefix, "--experimental-acp", "--model", model, "--sandbox", "false"], + { + cwd: process.cwd(), + env: buildPopupSafeEnv(), + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }, + ); + + let stderrBuffer = ""; + let closing = false; + let closeNotified = false; + + const notifyClose = (error?: Error) => { + if (closeNotified) { + return; + } + closeNotified = true; + handlers.onClose(error); + }; + + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrBuffer += chunk.toString(); + }); + + child.on("error", (error: Error) => { + notifyClose(error); + }); + + child.on("close", (code) => { + if (closing) { + notifyClose(); + return; + } + + const message = stderrBuffer.trim() || `Gemini ACP runtime exited with code ${code ?? "unknown"}.`; + notifyClose(new Error(message)); + }); + + const acp = (await import(pathToFileURL(acpModulePath).href)) as { + PROTOCOL_VERSION: number; + ndJsonStream: ( + output: WritableStream, + input: ReadableStream, + ) => unknown; + ClientSideConnection: new ( + clientFactory: () => { + requestPermission: GeminiAcpRuntimeHandlers["onRequestPermission"]; + sessionUpdate: GeminiAcpRuntimeHandlers["onSessionUpdate"]; + }, + stream: unknown, + ) => GeminiAcpConnection; + }; + + const connection = new acp.ClientSideConnection( + () => ({ + requestPermission: handlers.onRequestPermission, + sessionUpdate: async (notification: GeminiAcpNotification) => { + handlers.onSessionUpdate(notification); + }, + }), + acp.ndJsonStream( + Writable.toWeb(child.stdin as NonNullable), + Readable.toWeb(child.stdout as NonNullable), + ), + ); + + let initializePromise: Promise | null = null; + const initialize = async () => { + initializePromise ??= connection + .initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }) + .then(() => undefined); + return initializePromise; + }; + + return { + model, + initialize, + async newSession(cwd: string) { + await initialize(); + return connection.newSession({ cwd, mcpServers: [] }); + }, + async loadSession(sessionId: string, cwd: string) { + await initialize(); + return connection.loadSession({ sessionId, cwd, mcpServers: [] }); + }, + async setSessionMode(sessionId: string, modeId: GeminiSessionModeId) { + await initialize(); + await connection.setSessionMode({ sessionId, modeId }); + }, + async prompt(sessionId: string, prompt: ReadonlyArray) { + await initialize(); + return connection.prompt({ + sessionId, + prompt: prompt.length > 0 ? [...prompt] : [{ type: "text", text: "" }], + }); + }, + async cancel(sessionId: string) { + await initialize(); + await connection.cancel({ sessionId }); + }, + close() { + closing = true; + killChildTree(child); + }, + }; +} + +function readContentText(content: unknown): string | undefined { + if (!content || typeof content !== "object" || Array.isArray(content)) { + return undefined; + } + + const text = (content as GeminiAcpMessageContent).text; + return typeof text === "string" && text.length > 0 ? text : undefined; +} + +function flattenContentBlocks(content: unknown): string | undefined { + if (!Array.isArray(content)) { + return undefined; + } + + const text = content + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const contentBlock = (entry as { content?: unknown }).content; + return readContentText(contentBlock) ?? ""; + }) + .join(""); + + return text.length > 0 ? text : undefined; +} + +function flattenToolCallContent(content: unknown): string | undefined { + if (!Array.isArray(content)) { + return undefined; + } + + const text = content + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const record = entry as Record; + const entryType = typeof record.type === "string" ? record.type : ""; + if (entryType === "diff") { + const path = typeof record.path === "string" ? record.path : "file"; + return `Diff updated: ${path}`; + } + if (entryType === "terminal") { + const terminalId = typeof record.terminalId === "string" ? record.terminalId : "terminal"; + return `Terminal activity: ${terminalId}`; + } + const contentBlock = record.content; + return readContentText(contentBlock) ?? ""; + }) + .filter((entry) => entry.length > 0) + .join("\n"); + + return text.length > 0 ? text : undefined; +} + +function normalizeToolCallStatus(value: unknown): "pending" | "in_progress" | "completed" | "failed" { + switch (value) { + case "pending": + case "in_progress": + case "completed": + case "failed": + return value; + default: + return "in_progress"; + } +} + +function inputPromptBlocks( + text: string, + prompt: ReadonlyArray | undefined, +): ReadonlyArray { + if (prompt && prompt.length > 0) { + const promptHasText = prompt.some( + (entry): entry is Extract => + entry.type === "text" && entry.text.trim().length > 0, + ); + return promptHasText ? prompt : [{ type: "text", text }, ...prompt]; + } + return [{ type: "text", text }]; +} + +function stringifyUnknown(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (value === null || value === undefined) { + return undefined; + } + + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +function toSessionModeId(approvalMode: GeminiApprovalMode): GeminiSessionModeId { + switch (approvalMode) { + case "auto_edit": + return "autoEdit"; + case "plan": + return "plan"; + case "default": + return "default"; + case "yolo": + default: + return "yolo"; + } +} + +function asSessionModeId(value: unknown): GeminiSessionModeId | undefined { + return value === "default" || value === "autoEdit" || value === "yolo" || value === "plan" + ? value + : undefined; +} + +function toErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + if (typeof error === "string" && error.trim().length > 0) { + return error.trim(); + } + return fallback; +} + +function killChildTree(child: ChildProcess): void { + if (process.platform === "win32" && child.pid !== undefined) { + try { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // Fall through to direct kill. + } + } + + try { + child.kill("SIGTERM"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + // Process is already dead. + } + } +} diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a288b2f379..3d8ed2a8f4 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -8,6 +8,7 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const ENABLE_STATUS_UPSTREAM_REFRESH = process.env.T3CODE_ENABLE_STATUS_UPSTREAM_REFRESH === "1"; class StatusUpstreamRefreshCacheKey extends Data.Class<{ cwd: string; @@ -524,7 +525,9 @@ const makeGitCore = Effect.gen(function* () { const statusDetails: GitCoreShape["statusDetails"] = (cwd) => Effect.gen(function* () { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); + if (ENABLE_STATUS_UPSTREAM_REFRESH) { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); + } const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( [ diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index d0e6ebef6f..f2ab47206d 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,10 +1,14 @@ import { Effect, Layer } from "effect"; +import { buildNonInteractiveGitEnv } from "../../cliEnvironment"; import { runProcess } from "../../processRunner"; import { GitHubCliError } from "../Errors.ts"; import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const NON_INTERACTIVE_GH_ENV: NodeJS.ProcessEnv = buildNonInteractiveGitEnv(process.env, { + GH_PROMPT_DISABLED: "1", +}); function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { if (error instanceof Error) { @@ -106,6 +110,7 @@ const makeGitHubCli = Effect.sync(() => { runProcess("gh", input.args, { cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + env: NON_INTERACTIVE_GH_ENV, }), catch: (error) => normalizeGitHubCliError("execute", error), }); diff --git a/apps/server/src/git/Layers/GitService.ts b/apps/server/src/git/Layers/GitService.ts index d3f07e3151..412af7e2d0 100644 --- a/apps/server/src/git/Layers/GitService.ts +++ b/apps/server/src/git/Layers/GitService.ts @@ -8,6 +8,8 @@ */ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { buildNonInteractiveGitEnv } from "../../cliEnvironment"; import { GitCommandError } from "../Errors.ts"; import { ExecuteGitInput, @@ -18,6 +20,7 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const NON_INTERACTIVE_GIT_ENV = buildNonInteractiveGitEnv(process.env); function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; @@ -83,7 +86,10 @@ const makeGitService = Effect.gen(function* () { .spawn( ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, - ...(input.env ? { env: input.env } : {}), + env: { + ...NON_INTERACTIVE_GIT_ENV, + ...input.env, + }, }), ) .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..9330bb28f6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -9,6 +9,8 @@ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; + +// Dummy comment. import { DEFAULT_PORT, resolveStaticDir, @@ -20,12 +22,13 @@ import { fixPath, resolveStateDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { FetchHttpClient } from "effect/unstable/http"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -198,9 +201,11 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), Layer.provideMerge(ProviderHealthLive), + Layer.provideMerge( + AnalyticsServiceLayerLive.pipe(Layer.provide(FetchHttpClient.layer)), + ), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -210,31 +215,6 @@ const isWildcardHost = (host: string | undefined): boolean => const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; -export const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - - const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( - Effect.map((snapshot) => ({ - threadCount: snapshot.threads.length, - projectCount: snapshot.projects.length, - })), - Effect.catch((cause) => - Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( - Effect.as({ - threadCount: 0, - projectCount: 0, - }), - ), - ), - ); - - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, - }); -}); - const makeServerProgram = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; @@ -254,7 +234,6 @@ const makeServerProgram = (input: CliInput) => } yield* start; - yield* Effect.forkChild(recordStartupHeartbeat); const localUrl = `http://localhost:${config.port}`; const bindUrl = @@ -331,6 +310,16 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( Flag.optional, ); +export const recordStartupHeartbeat = Effect.gen(function* () { + const analytics = yield* AnalyticsService; + const { getSnapshot } = yield* ProjectionSnapshotQuery; + const snapshot = yield* getSnapshot(); + yield* analytics.record("server.boot.heartbeat", { + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + }); +}); + export const t3Cli = Command.make("t3", { mode: modeFlag, port: portFlag, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index d675c85ff5..6bbeeddb28 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -43,7 +43,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -57,7 +57,7 @@ function createProviderServiceHarness( cwd: string, hasSession = true, sessionCwd = cwd, - providerName: ProviderSession["provider"] = "codex", + providerName: "codex" | "claudeCode" = "codex", ) { const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -91,6 +91,7 @@ function createProviderServiceHarness( listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation, + stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -234,6 +235,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; + readonly providerName?: "codex" | "claudeCode"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -241,7 +243,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - "codex", + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -474,6 +476,67 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); + it("captures pre-turn and completion checkpoints for claudeCode runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeCode", + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, + ); + + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + }); + it("appends capture failure activity when turn diff summary cannot be derived", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -780,7 +843,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); @@ -789,6 +852,75 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("executes provider revert and emits thread.reverted for claudeCode sessions", async () => { + const harness = await createHarness({ providerName: "claudeCode" }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, + }); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -874,11 +1006,11 @@ describe("CheckpointReactor", () => { expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4f352435fe..f3b6221f6a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -93,7 +93,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || + input.provider === "claudeCode" || + input.provider === "cursor") ? input.provider : "codex"; const resumeCursor = @@ -185,9 +187,10 @@ describe("ProviderCommandReactor", () => { listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (provider) => Effect.succeed({ - sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", + sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session", }), rollbackConversation: () => unsupported(), + stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -382,6 +385,80 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts first turn with requested provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-first"), + role: "user", + text: "hello claude", + attachments: [], + }, + provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + 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?.threadId).toBe("thread-1"); + }); + + it("starts first turn with cursor provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-cursor"), + role: "user", + text: "hello cursor", + attachments: [], + }, + provider: "cursor", + 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: "cursor", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + 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("cursor"); + expect(thread?.session?.threadId).toBe("thread-1"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -428,6 +505,111 @@ 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(); + 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"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-1"), + role: "user", + text: "first", + 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: "cursor", + model: "gpt-5.3-codex", + 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-change-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-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.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", + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -513,6 +695,66 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("switches provider by restarting the session when turn request provider changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "claudeCode", + 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[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + provider: "claudeCode", + runtimeMode: "approval-required", + }); + expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty("resumeCursor"); + + 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?.runtimeMode).toBe("approval-required"); + }); + it("does not stop the active session when restart fails before rebind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -708,7 +950,7 @@ describe("ProviderCommandReactor", () => { harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "codex", + provider: "cursor", method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -723,7 +965,7 @@ describe("ProviderCommandReactor", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", - providerName: "codex", + providerName: "cursor", runtimeMode: "approval-required", activeTurnId: null, lastError: null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc20..a084798c76 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -5,7 +5,6 @@ import { type OrchestrationEvent, type ProviderModelOptions, type ProviderKind, - type ProviderServiceTier, type OrchestrationSession, ThreadId, type ProviderSession, @@ -202,7 +201,6 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; readonly model?: string; readonly modelOptions?: ProviderModelOptions; - readonly serviceTier?: ProviderServiceTier | null; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -213,7 +211,12 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || + thread.session?.providerName === "claudeCode" || + thread.session?.providerName === "cursor" || + thread.session?.providerName === "gemini" + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -237,7 +240,6 @@ const make = Effect.gen(function* () { : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(desiredModel ? { model: desiredModel } : {}), - ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, @@ -323,7 +325,6 @@ const make = Effect.gen(function* () { readonly attachments?: ReadonlyArray; readonly provider?: ProviderKind; readonly model?: string; - readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -335,7 +336,6 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); @@ -355,7 +355,6 @@ const make = Effect.gen(function* () { ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); @@ -470,7 +469,6 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), - ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846c..a241cb0afa 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor" | "gemini"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -69,6 +69,7 @@ function createProviderServiceHarness() { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), + stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -399,6 +400,116 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { + const harness = await createHarness(); + const seededAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-claude-placeholder", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + status: "completed", + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + + it("preserves running status when provider emits session ready during an active turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-ready-heartbeat"), + provider: "gemini", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ready-heartbeat"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-ready-heartbeat", + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-ready-midturn"), + provider: "gemini", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ready-heartbeat"), + payload: { + state: "ready", + reason: "runtime warm", + }, + }); + + await Effect.runPromise(Effect.sleep("40 millis")); + const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); + const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(midThread?.session?.status).toBe("running"); + expect(midThread?.session?.activeTurnId).toBe("turn-ready-heartbeat"); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-ready-heartbeat"), + provider: "gemini", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ready-heartbeat"), + payload: { + state: "completed", + }, + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + it("ignores auxiliary turn completions from a different provider thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -452,6 +563,60 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { + const harness = await createHarness(); + const seededAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-claude-placeholder", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + status: "completed", + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 022a196674..c7534a7de4 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -155,6 +155,36 @@ function orchestrationSessionStatusFromRuntimeState( } } +function statusFromLifecycleEvent( + event: + | Extract + | Extract + | Extract + | Extract + | Extract + | Extract, + activeTurnId: string | null, +): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { + switch (event.type) { + case "session.state.changed": + // Some providers emit a "ready" heartbeat while a turn is still actively running. + // Preserve the run lock until the active turn closes. + if (activeTurnId !== null && event.payload.state === "ready") { + return "running"; + } + return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "turn.started": + return "running"; + case "session.exited": + return "stopped"; + case "turn.completed": + return runtimeTurnState(event) === "failed" ? "error" : "ready"; + case "session.started": + case "thread.started": + return activeTurnId !== null ? "running" : "ready"; + } +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -839,23 +869,7 @@ const make = Effect.gen(function* () { : event.type === "turn.completed" || event.type === "session.exited" ? null : activeTurnId; - const status = (() => { - switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); - case "turn.started": - return "running"; - case "session.exited": - return "stopped"; - case "turn.completed": - return runtimeTurnState(event) === "failed" ? "error" : "ready"; - case "session.started": - case "thread.started": - // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; - } - })(); + const status = statusFromLifecycleEvent(event, activeTurnId); const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f036416429..7fe8eb746d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -301,7 +301,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.message.messageId, ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), - ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..b02d0fdcba 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,18 +1,20 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { readEnvFromLoginShell } from "@t3tools/shared/shell"; export function fixPath(): void { if (process.platform !== "darwin") return; try { const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; + const env = readEnvFromLoginShell(shell); + if (env) { + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } } } catch { - // Silently ignore — keep default PATH + // Silently ignore — keep default environment } } diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts new file mode 100644 index 0000000000..cd55c39837 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -0,0 +1,930 @@ +import type { + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Random, Stream } from "effect"; + +import { + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; +import { + makeClaudeCodeAdapterLive, + type ClaudeCodeAdapterLiveOptions, +} from "./ClaudeCodeAdapter.ts"; + +class FakeClaudeQuery implements AsyncIterable { + private readonly queue: Array = []; + private readonly resolvers: Array<(value: IteratorResult) => void> = []; + private done = false; + + public readonly interruptCalls: Array = []; + public readonly setModelCalls: Array = []; + public readonly setPermissionModeCalls: Array = []; + public readonly setMaxThinkingTokensCalls: Array = []; + public closeCalls = 0; + + emit(message: SDKMessage): void { + if (this.done) { + return; + } + const resolver = this.resolvers.shift(); + if (resolver) { + resolver({ done: false, value: message }); + return; + } + this.queue.push(message); + } + + finish(): void { + if (this.done) { + return; + } + this.done = true; + for (const resolver of this.resolvers.splice(0)) { + resolver({ done: true, value: undefined }); + } + } + + readonly interrupt = async (): Promise => { + this.interruptCalls.push(undefined); + }; + + readonly setModel = async (model?: string): Promise => { + this.setModelCalls.push(model); + }; + + readonly setPermissionMode = async (mode: PermissionMode): Promise => { + this.setPermissionModeCalls.push(mode); + }; + + readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise => { + this.setMaxThinkingTokensCalls.push(maxThinkingTokens); + }; + + readonly close = (): void => { + this.closeCalls += 1; + this.finish(); + }; + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.queue.length > 0) { + const value = this.queue.shift(); + if (value) { + return Promise.resolve({ + done: false, + value, + }); + } + } + if (this.done) { + return Promise.resolve({ + done: true, + value: undefined, + }); + } + return new Promise((resolve) => { + this.resolvers.push(resolve); + }); + }, + }; + } +} + +interface Harness { + readonly layer: ReturnType; + readonly query: FakeClaudeQuery; + readonly getLastCreateQueryInput: () => + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; +} + +function makeHarness(config?: { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: ClaudeCodeAdapterLiveOptions["nativeEventLogger"]; +}): Harness { + const query = new FakeClaudeQuery(); + let createInput: + | { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + } + | undefined; + + const adapterOptions: ClaudeCodeAdapterLiveOptions = { + createQuery: (input) => { + createInput = input; + return query; + }, + ...(config?.nativeEventLogger + ? { + nativeEventLogger: config.nativeEventLogger, + } + : {}), + ...(config?.nativeEventLogPath + ? { + nativeEventLogPath: config.nativeEventLogPath, + } + : {}), + }; + + return { + layer: makeClaudeCodeAdapterLive(adapterOptions), + query, + getLastCreateQueryInput: () => createInput, + }; +} + +function makeDeterministicRandomService(seed = 0x1234_5678): { + nextIntUnsafe: () => number; + nextDoubleUnsafe: () => number; +} { + let state = seed >>> 0; + const nextIntUnsafe = (): number => { + state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0; + return state; + }; + + return { + nextIntUnsafe, + nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000, + }; +} + + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); + +describe("ClaudeCodeAdapterLive", () => { + it.effect("returns validation error for non-claudeCode provider on startSession", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + const result = yield* adapter + .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "claudeCode", + operation: "startSession", + issue: "Expected provider 'claudeCode' but received 'codex'.", + }), + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("derives bypass permission mode from full-access runtime policy", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + permissionMode: "plan", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "plan"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + model: "claude-sonnet-4-5", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-1", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Hi", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-2", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-1", + name: "Bash", + input: { + command: "ls", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-3", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "content.delta", + "item.started", + "item.completed", + "item.updated", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = runtimeEvents[3]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(String(turnStarted.turnId), String(turn.turnId)); + } + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Hi"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); + } + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("emits completion only after turn result when assistant frames arrive before deltas", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-early-assistant", + uuid: "assistant-early", + parent_tool_use_id: null, + message: { + id: "assistant-message-early", + content: [{ type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-early-assistant", + uuid: "stream-early", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "text_delta", + text: "Late text", + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-early-assistant", + uuid: "result-early", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta"); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed"); + assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); + + const deltaEvent = runtimeEvents[deltaIndex]; + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Late text"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("falls back to assistant payload text when stream deltas are absent", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-fallback-text", + uuid: "assistant-fallback", + parent_tool_use_id: null, + message: { + id: "assistant-message-fallback", + content: [{ type: "text", text: "Fallback hello" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-fallback-text", + uuid: "result-fallback", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Fallback hello"); + assert.equal(String(deltaEvent.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not fabricate provider thread ids before first SDK session_id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + assert.equal(session.threadId, undefined); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + assert.equal(turn.threadId, undefined); + + 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("threadId" in sessionStarted, false); + } + + const threadStarted = runtimeEvents[4]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.equal(threadStarted.threadId, "sdk-thread-real"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("bridges approval request/response lifecycle through canUseTool", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "pwd" }, + { + signal: new AbortController().signal, + suggestions: [ + { + type: "setMode", + mode: "default", + destination: "session", + }, + ], + toolUseID: "tool-use-1", + }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some") { + return; + } + assert.equal(requested.value.type, "request.opened"); + if (requested.value.type !== "request.opened") { + return; + } + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(runtimeRequestId), + "accept", + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + assert.equal(resolved.value.requestId, requested.value.requestId); + assert.equal(resolved.value.payload.decision, "accept"); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal((permissionResult as PermissionResult).behavior, "allow"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("passes parsed resume cursor values to Claude query options", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: "claudeCode", + resumeCursor: { + threadId: "resume-thread-1", + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + assert.equal(session.threadId, "resume-thread-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("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "first", + attachments: [], + }); + + const firstCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe( + Stream.runHead, + Effect.forkChild, + ); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-first", + } as unknown as SDKMessage); + + const firstCompleted = yield* Fiber.join(firstCompletedFiber); + assert.equal(firstCompleted._tag, "Some"); + if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { + assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + } + + const secondTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "second", + attachments: [], + }); + + const secondCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe( + Stream.runHead, + Effect.forkChild, + ); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-rollback", + uuid: "result-second", + } as unknown as SDKMessage); + + const secondCompleted = yield* Fiber.join(secondCompletedFiber); + assert.equal(secondCompleted._tag, "Some"); + if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { + assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); + } + + const threadBeforeRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadBeforeRollback.turns.length, 2); + + const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); + assert.equal(rolledBack.turns.length, 1); + assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); + + const threadAfterRollback = yield* adapter.readThread(session.threadId); + assert.equal(threadAfterRollback.turns.length, 1); + assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("updates model on sendTurn when model override is provided", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + model: "claude-opus-4-6", + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("writes provider-native observability records when enabled", () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + turnId?: string; + }; + }> = []; + const harness = makeHarness({ + nativeEventLogger: { + filePath: "memory://claude-native-events", + write: (event) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + 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/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts new file mode 100644 index 0000000000..9fadc3ea54 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,1858 @@ +/** + * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * + * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic + * provider adapter contract and emits canonical runtime events. + * + * @module ClaudeCodeAdapterLive + */ +import { + type CanUseTool, + query, + type Options as ClaudeQueryOptions, + type PermissionMode, + type PermissionResult, + type PermissionUpdate, + type SDKMessage, + type SDKResultMessage, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "claudeCode" as const; + +type PromptQueueItem = + | { + readonly type: "message"; + readonly message: SDKUserMessage; + } + | { + readonly type: "terminate"; + }; + +interface ClaudeResumeState { + readonly threadId?: ThreadId; + readonly resume?: string; + readonly resumeSessionAt?: string; + readonly turnCount?: number; +} + +interface ClaudeTurnState { + readonly turnId: TurnId; + readonly assistantItemId: string; + readonly startedAt: string; + readonly items: Array; + readonly messageCompleted: boolean; + readonly emittedTextDelta: boolean; + readonly fallbackAssistantText: string; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly suggestions?: ReadonlyArray; + readonly decision: Deferred.Deferred; +} + +interface ToolInFlight { + readonly itemId: string; + readonly itemType: CanonicalItemType; + readonly toolName: string; + readonly title: string; + readonly detail?: string; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly promptQueue: Queue.Queue; + readonly query: ClaudeQueryRuntime; + readonly startedAt: string; + resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + readonly inFlightTools: Map; + turnState: ClaudeTurnState | undefined; + lastAssistantUuid: string | undefined; + lastThreadStartedId: string | undefined; + stopped: boolean; +} + +interface ClaudeQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; + readonly setModel: (model?: string) => Promise; + readonly setPermissionMode: (mode: PermissionMode) => Promise; + readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly close: () => void; +} + +export interface ClaudeCodeAdapterLiveOptions { + readonly createQuery?: (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => ClaudeQueryRuntime; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function isSyntheticClaudeThreadId(value: string): boolean { + return value.startsWith("claude-thread-"); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + +function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const cursor = resumeCursor as { + threadId?: unknown; + resume?: unknown; + sessionId?: unknown; + resumeSessionAt?: unknown; + turnCount?: unknown; + }; + + const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; + const threadId = + threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) + ? ThreadId.makeUnsafe(threadIdCandidate) + : undefined; + const resumeCandidate = + typeof cursor.resume === "string" + ? cursor.resume + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; + const resumeSessionAt = + typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; + const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + + return { + ...(threadId ? { threadId } : {}), + ...(resume ? { resume } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 + ? { turnCount: turnCountValue } + : {}), + }; +} + +function classifyToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; +} + +function classifyRequestType(toolName: string): CanonicalRequestType { + const normalized = toolName.toLowerCase(); + if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + return "file_read_approval"; + } + return classifyToolItemType(toolName) === "command_execution" + ? "command_execution_approval" + : "file_change_approval"; +} + +function summarizeToolRequest(toolName: string, input: Record): string { + const commandValue = input.command ?? input.cmd; + const command = typeof commandValue === "string" ? commandValue : undefined; + if (command && command.trim().length > 0) { + return `${toolName}: ${command.trim().slice(0, 400)}`; + } + + const serialized = JSON.stringify(input); + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { + const fragments: string[] = []; + + if (input.input && input.input.trim().length > 0) { + fragments.push(input.input.trim()); + } + + for (const attachment of input.attachments ?? []) { + if (attachment.type === "image") { + fragments.push( + `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, + ); + } + } + + const text = fragments.join("\n\n"); + + return { + type: "user", + session_id: "", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text }], + }, + } as SDKUserMessage; +} + +function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if (result.subtype === "success") { + return "completed"; + } + + const errors = result.errors.join(" ").toLowerCase(); + if (errors.includes("interrupt")) { + return "interrupted"; + } + if (errors.includes("cancel")) { + return "cancelled"; + } + return "failed"; +} + +function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + +function providerThreadRef( + context: ClaudeSessionContext, +): { readonly providerThreadId: string } | {} { + return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +} + +function extractAssistantText(message: SDKMessage): string { + if (message.type !== "assistant") { + return ""; + } + + const content = (message.message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) { + return ""; + } + + const fragments: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const candidate = block as { type?: unknown; text?: unknown }; + if ( + candidate.type === "text" && + typeof candidate.text === "string" && + candidate.text.length > 0 + ) { + fragments.push(candidate.text); + } + } + + return fragments.join(""); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError( + threadId: ThreadId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function sdkMessageType(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { type?: unknown }; + return typeof record.type === "string" ? record.type : undefined; +} + +function sdkMessageSubtype(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { subtype?: unknown }; + return typeof record.subtype === "string" ? record.subtype : undefined; +} + +function sdkNativeMethod(message: SDKMessage): string { + const subtype = sdkMessageSubtype(message); + if (subtype) { + return `claude/${message.type}/${subtype}`; + } + + if (message.type === "stream_event") { + const streamType = sdkMessageType(message.event); + if (streamType) { + const deltaType = + streamType === "content_block_delta" + ? sdkMessageType((message.event as { delta?: unknown }).delta) + : undefined; + if (deltaType) { + return `claude/${message.type}/${streamType}/${deltaType}`; + } + return `claude/${message.type}/${streamType}`; + } + } + + return `claude/${message.type}`; +} + +function sdkNativeItemId(message: SDKMessage): string | undefined { + if (message.type === "assistant") { + const maybeId = (message.message as { id?: unknown }).id; + if (typeof maybeId === "string") { + return maybeId; + } + return undefined; + } + + if (message.type === "stream_event") { + const event = message.event as { + type?: unknown; + content_block?: { id?: unknown }; + }; + if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { + return event.content_block.id; + } + } + + return undefined; +} + +function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const createQuery = + options?.createQuery ?? + ((input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const logNativeSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } + + const observedAt = new Date().toISOString(); + const itemId = sdkNativeItemId(message); + + yield* nativeEventLogger + .write( + { + observedAt, + event: { + id: + "uuid" in message && typeof message.uuid === "string" + ? message.uuid + : crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method: sdkNativeMethod(message), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + payload: message, + }, + }, + null, + ); + }); + + const snapshotThread = ( + context: ClaudeSessionContext, + ): Effect.Effect<{ + threadId: ThreadId; + turns: ReadonlyArray<{ + id: TurnId; + items: ReadonlyArray; + }>; + }, ProviderAdapterValidationError> => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "readThread", + issue: "Session thread id is not initialized yet.", + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) return; + + const resumeCursor = { + threadId, + ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), + ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + turnCount: context.turns.length, + }; + + context.session = { + ...context.session, + resumeCursor, + updatedAt: yield* nowIso, + }; + }); + + const ensureThreadId = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (typeof message.session_id !== "string" || message.session_id.length === 0) { + return; + } + const nextThreadId = message.session_id; + context.resumeSessionId = message.session_id; + yield* updateResumeCursor(context); + + if (context.lastThreadStartedId !== nextThreadId) { + context.lastThreadStartedId = nextThreadId; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: {}, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, + }); + } + }); + + const emitRuntimeError = ( + context: ClaudeSessionContext, + message: string, + cause?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + if (cause !== undefined) { + void cause; + } + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const completeTurn = ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + errorMessage?: string, + result?: SDKResultMessage, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: {}, + }); + return; + } + + if (!turnState.messageCompleted) { + if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: turnState.fallbackAssistantText, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: String(turnState.turnId), + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(turnState.assistantItemId), + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + }, + }); + + const updatedAt = yield* nowIso; + context.turnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + yield* updateResumeCursor(context); + }); + + const handleStreamEvent = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "stream_event") { + return; + } + + const { event } = message; + + if (event.type === "content_block_delta") { + if ( + event.delta.type === "text_delta" && + event.delta.text.length > 0 && + context.turnState + ) { + if (!context.turnState.emittedTextDelta) { + context.turnState = { + ...context.turnState, + emittedTextDelta: true, + }; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: streamKindFromDeltaType(event.delta.type), + delta: event.delta.text, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { + return; + } + + const toolName = block.name; + const itemType = classifyToolItemType(toolName); + const toolInput = + typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; + const itemId = block.id; + const detail = summarizeToolRequest(toolName, toolInput); + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + }; + context.inFlightTools.set(index, tool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + + if (event.type === "content_block_stop") { + const { index } = event; + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + context.inFlightTools.delete(index); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "completed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_stop", + payload: message, + }, + }); + } + }); + + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } + + if (context.turnState) { + context.turnState.items.push(message.message); + const fallbackAssistantText = extractAssistantText(message); + if ( + fallbackAssistantText.length > 0 && + fallbackAssistantText !== context.turnState.fallbackAssistantText + ) { + context.turnState = { + ...context.turnState, + fallbackAssistantText, + }; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + data: message.message, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/assistant", + payload: message, + }, + }); + } + + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + }); + + const handleResultMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "result") { + return; + } + + const status = turnStatusFromResult(message); + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + + if (status === "failed") { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } + + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, + }, + }; + + switch (message.subtype) { + case "init": + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + payload: { + config: message as Record, + }, + }); + return; + case "status": + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, + }, + }); + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + }, + }); + return; + case "task_progress": + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + }, + }); + return; + case "task_notification": + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + }, + }); + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${message.subtype}'.`, + message, + ); + return; + } + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, + }, + }; + + if (message.type === "tool_progress") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + }, + }); + return; + } + + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } + + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } + + if (message.type === "rate_limit_event") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } + }); + + const handleSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + yield* logNativeSdkMessage(context, message); + yield* ensureThreadId(context, message); + + switch (message.type) { + case "stream_event": + yield* handleStreamEvent(context, message); + return; + case "user": + return; + case "assistant": + yield* handleAssistantMessage(context, message); + return; + case "result": + yield* handleResultMessage(context, message); + return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit_event": + yield* handleSdkTelemetryMessage(context, message); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${message.type}'.`, + message, + ); + return; + } + }); + + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( + Stream.takeWhile(() => !context.stopped), + Stream.runForEach((message) => handleSdkMessage(context, message)), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause) || context.stopped) { + return; + } + const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); + yield* emitRuntimeError(context, message, cause); + yield* completeTurn(context, "failed", message); + }), + ), + ); + + const stopSessionInternal = ( + context: ClaudeSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) return; + + context.stopped = true; + + for (const [requestId, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + }); + } + context.pendingApprovals.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped."); + } + + yield* Queue.shutdown(context.promptQueue); + + context.query.close(); + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: {}, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const resumeState = readClaudeResumeState(input.resumeCursor); + const threadId = input.threadId; + + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.filter((item) => item.type === "message"), + Stream.map((item) => item.message), + Stream.toAsyncIterable, + ); + + const pendingApprovals = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => + Effect.runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + if (!context) { + return { + behavior: "deny", + message: "Claude session context is unavailable.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); + + const providerOptions = input.providerOptions?.claudeCode; + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(providerOptions?.binaryPath + ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } + : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + includePartialMessages: true, + canUseTool, + executable: "node" as const, + env: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); + + const session: ProviderSession = { + threadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + startedAt, + resumeSessionId: resumeState?.resume, + pendingApprovals, + turns: [], + inFlightTools, + turnState: undefined, + lastAssistantUuid: resumeState?.resumeSessionAt, + lastThreadStartedId: undefined, + stopped: false, + }; + yield* Ref.set(contextRef, context); + sessions.set(threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, + }); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + createdAt: configuredStamp.createdAt, + threadId, + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + }, + }, + providerRefs: {}, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", + }, + providerRefs: {}, + }); + + Effect.runFork(runSdkStream(context)); + + return { + ...session, + }; + }); + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, + }); + } + + if (input.model) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(input.model), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + assistantItemId: yield* Random.nextUUIDv4, + startedAt: yield* nowIso, + items: [], + messageCompleted: false, + emittedTextDelta: false, + fallbackAssistantText: "", + }; + + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const message = buildUserMessage(input); + + yield* Queue.offer(context.promptQueue, { + type: "message", + message, + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + }); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); + }); + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/requestApproval/decision", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/requestUserInput", + detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeCodeAdapterShape; + }); +} + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c977f5dadc..4e1456e3a8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -24,6 +24,7 @@ import { import { ServerConfig } from "../../config.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { ProviderAdapterValidationError } from "../Errors.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -156,6 +157,31 @@ const validationLayer = it.layer( ); validationLayer("CodexAdapterLive validation", (it) => { + it.effect("returns validation error for non-codex provider on startSession", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .startSession({ + provider: "claudeCode", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "Expected provider 'codex' but received 'claudeCode'.", + }), + ); + assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); + }), + ); + + it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { validationManager.startSessionImpl.mockClear(); @@ -443,78 +469,6 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - it.effect("preserves file-read request type when mapping serverRequest/resolved", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - - const event: ProviderEvent = { - id: asEventId("evt-file-read-request-resolved"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "serverRequest/resolved", - requestId: ApprovalRequestId.makeUnsafe("req-file-read-1"), - payload: { - request: { - method: "item/fileRead/requestApproval", - }, - decision: "accept", - }, - }; - - lifecycleManager.emit("event", event); - const firstEvent = yield* Fiber.join(firstEventFiber); - - assert.equal(firstEvent._tag, "Some"); - if (firstEvent._tag !== "Some") { - return; - } - assert.equal(firstEvent.value.type, "request.resolved"); - if (firstEvent.value.type !== "request.resolved") { - return; - } - assert.equal(firstEvent.value.payload.requestType, "file_read_approval"); - }), - ); - - it.effect("preserves explicit empty multi-select user-input answers", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); - - const event: ProviderEvent = { - id: asEventId("evt-user-input-empty"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "item/tool/requestUserInput/answered", - payload: { - answers: { - scope: [], - }, - }, - }; - - lifecycleManager.emit("event", event); - const firstEvent = yield* Fiber.join(firstEventFiber); - - assert.equal(firstEvent._tag, "Some"); - if (firstEvent._tag !== "Some") { - return; - } - assert.equal(firstEvent.value.type, "user-input.resolved"); - if (firstEvent.value.type !== "user-input.resolved") { - return; - } - assert.deepEqual(firstEvent.value.payload.answers, { - scope: [], - }); - }), - ); - it.effect("maps windowsSandbox/setupCompleted to session state and warning on failure", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 9a6271b792..d26fbe35b6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -274,6 +274,9 @@ function toCanonicalUserInputAnswers( if (Array.isArray(value)) { const normalized = value.filter((entry): entry is string => typeof entry === "string"); + if (normalized.length === 0) { + return []; + } return [[questionId, normalized.length === 1 ? normalized[0] : normalized] as const]; } @@ -281,7 +284,7 @@ function toCanonicalUserInputAnswers( const answerList = asArray(answerObject?.answers)?.filter( (entry): entry is string => typeof entry === "string", ); - if (!answerList) { + if (!answerList || answerList.length === 0) { return []; } return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; @@ -1354,7 +1357,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions?.codex?.reasoningEffort !== undefined ? { effort: input.modelOptions.codex.reasoningEffort } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 0000000000..76a46d106e --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,655 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import readline from "node:readline"; + +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); +const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy"); + +class FakeCursorAcpProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly requests: Array<{ method: string; params: unknown }> = []; + killed = false; + + private readonly input = readline.createInterface({ input: this.stdin }); + private permissionRequestId = 700; + lastPermissionSelection: string | undefined; + + constructor() { + super(); + this.input.on("line", (line) => { + const message = JSON.parse(line) as Record; + if (typeof message.method === "string") { + this.handleRequest(message); + return; + } + + if (message.id === this.permissionRequestId) { + const optionId = + (message.result as { outcome?: { optionId?: unknown } } | undefined)?.outcome?.optionId; + if (typeof optionId === "string") { + this.lastPermissionSelection = optionId; + } + } + }); + } + + kill(): boolean { + if (this.killed) { + return true; + } + this.killed = true; + this.emit("exit", 0, null); + return true; + } + + emitPermissionRequest(): void { + this.emitServerMessage({ + jsonrpc: "2.0", + id: this.permissionRequestId, + method: "session/request_permission", + params: { + sessionId: "acp-session-1", + toolCall: { + toolCallId: "tool-perm-1", + kind: "execute", + title: "`pwd`", + }, + options: [ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ], + }, + }); + } + + private handleRequest(message: Record): void { + const method = message.method; + const id = message.id; + if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) { + return; + } + this.requests.push({ method, params: message.params }); + + switch (method) { + case "initialize": { + const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined) + ?.protocolVersion; + if (typeof protocolVersion !== "number") { + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: "Invalid params", + data: { + _errors: [], + protocolVersion: { + _errors: ["Invalid input: expected number, received undefined"], + }, + }, + }, + }); + return; + } + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + }, + authMethods: [{ id: "cursor_login" }], + }, + }); + return; + } + case "authenticate": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/new": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + sessionId: "acp-session-1", + modes: { + currentModeId: "agent", + availableModes: [{ id: "agent" }], + }, + }, + }); + return; + case "session/load": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/set_model": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/prompt": { + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + kind: "execute", + title: "`pwd`", + rawInput: { command: "pwd" }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "/tmp/project", + stderr: "", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + stopReason: "end_turn", + }, + }); + return; + } + case "session/cancel": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: "Method not found", + }, + }); + return; + default: + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); + } + } + + private emitServerMessage(message: unknown): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +describe("CursorAdapterLive", () => { + it.effect("returns validation error for non-cursor provider on startSession", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter + .startSession({ + provider: "codex", + threadId: THREAD_ID, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "startSession", + issue: "Expected provider 'cursor' but received 'codex'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps ACP prompt/update events into canonical runtime events", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.configured", + "auth.status", + "auth.status", + "session.started", + "thread.started", + "session.state.changed", + "turn.started", + "content.delta", + "content.delta", + "item.started", + "item.completed", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = events[6]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(String(turnStarted.turnId), String(turn.turnId)); + } + + const completion = events[12]; + assert.equal(completion?.type, "turn.completed"); + if (completion?.type === "turn.completed") { + assert.equal(completion.payload.state, "completed"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes requested model to ACP process startup", () => { + const fake = new FakeCursorAcpProcess(); + let createProcessInput: + | { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + } + | undefined; + const layer = makeCursorAdapterLive({ + createProcess: (input) => { + createProcessInput = input; + return fake as never; + }, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + model: "composer-1.5", + runtimeMode: "full-access", + }); + + assert.deepEqual(createProcessInput?.model, "composer-1.5"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("writes provider-native observability records when enabled", () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + }; + }> = []; + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + nativeEventLogger: { + filePath: "memory://cursor-native-events", + write: (event) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + assert.equal(nativeEvents.length > 0, true); + assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); + assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true); + assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resumes ACP session using resumeCursor.acpSessionId", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: RESUME_THREAD_ID, + cwd: "/tmp/project", + resumeCursor: { + acpSessionId: "acp-session-resume", + }, + runtimeMode: "full-access", + }); + + const methods = new Set(fake.requests.map((request) => request.method)); + assert.equal(methods.has("session/load"), true); + assert.equal(methods.has("session/new"), false); + + const loadRequest = fake.requests.find((request) => request.method === "session/load"); + assert.deepEqual(loadRequest?.params, { + sessionId: "acp-session-resume", + cwd: "/tmp/project", + mcpServers: [], + }); + assert.equal(session.threadId, RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + acpSessionId: "acp-session-resume", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("accepts legacy resumeCursor.sessionId for ACP session resume", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: LEGACY_RESUME_THREAD_ID, + cwd: "/tmp/project", + resumeCursor: { + sessionId: "acp-session-legacy", + }, + runtimeMode: "full-access", + }); + + const loadRequest = fake.requests.find((request) => request.method === "session/load"); + assert.deepEqual(loadRequest?.params, { + sessionId: "acp-session-legacy", + cwd: "/tmp/project", + mcpServers: [], + }); + assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + acpSessionId: "acp-session-legacy", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("bridges permission requests to request.opened/request.resolved", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "approval-required", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + const opened = yield* Stream.runHead(adapter.streamEvents); + assert.equal(opened._tag, "Some"); + if (opened._tag !== "Some") { + return; + } + assert.equal(opened.value.type, "request.opened"); + if (opened.value.type !== "request.opened") { + return; + } + const runtimeRequestId = opened.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(runtimeRequestId), + "acceptForSession", + ); + + 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.payload.decision, "acceptForSession"); + assert.equal(fake.lastPermissionSelection, "allow-always"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("auto-approves cursor permission requests when approval policy is never", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + 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.payload.decision, "acceptForSession"); + assert.equal(fake.lastPermissionSelection, "allow-always"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects empty prompt input before starting a turn", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + const result = yield* adapter + .sendTurn({ + threadId: session.threadId, + input: " ", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }), + ); + + assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps tool_call item types consistent through tool_call_update", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find( + (event) => event.type === "item.started" && String(event.itemId) === "tool-1", + ); + const completed = events.find( + (event) => event.type === "item.completed" && String(event.itemId) === "tool-1", + ); + + assert.equal(started?.type, "item.started"); + assert.equal(completed?.type, "item.completed"); + if (started?.type !== "item.started" || completed?.type !== "item.completed") { + return; + } + + assert.equal(started.payload.itemType, "command_execution"); + assert.equal(completed.payload.itemType, "command_execution"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 0000000000..15423b0c7c --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1566 @@ +/** + * CursorAdapterLive - Scoped live implementation for the Cursor ACP provider adapter. + * + * Spawns `agent acp` over stdio, manages JSON-RPC session lifecycle, and maps + * ACP notifications/requests into canonical provider runtime events. + * + * @module CursorAdapterLive + */ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import readline from "node:readline"; + +import { + ApprovalRequestId, + EventId, + type CanonicalItemType, + type CanonicalRequestType, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type RuntimeMode, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Queue, Random, Schema, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + CursorAdapter, + type CursorAdapterShape, + CursorAcpInitializeResult, + CursorAcpPermissionRequest, + CursorAcpSessionNewResult, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "../Services/CursorAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "cursor" as const; +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +const CURSOR_ACP_PROTOCOL_VERSION = 1; + +interface CursorResumeState { + readonly acpSessionId?: string; +} + +interface PendingRequest { + readonly method: string; + readonly timeout: ReturnType; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; +} + +interface PendingPermission { + readonly jsonRpcId: string | number; + readonly requestType: CanonicalRequestType; + readonly options: ReadonlyArray<{ optionId: string }>; +} + +interface CursorTurnState { + readonly turnId: TurnId; + readonly assistantItemId: ReturnType; + readonly startedToolCalls: Set; + readonly toolCalls: Map; + readonly items: Array; +} + +interface CursorSessionContext { + session: ProviderSession; + runtimeMode: RuntimeMode; + readonly child: ChildProcessWithoutNullStreams; + readonly output: readline.Interface; + readonly pending: Map; + readonly pendingPermissions: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + turnState: CursorTurnState | undefined; + acpSessionId: string; + nextRpcId: number; + stopping: boolean; +} + +export interface CursorAdapterLiveOptions { + readonly createProcess?: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + }) => ChildProcessWithoutNullStreams; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asProviderItemId(value: string): ProviderItemId { + return ProviderItemId.makeUnsafe(value); +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError( + threadId: ThreadId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType { + const normalizedKind = asString(kind)?.toLowerCase(); + const normalizedTitle = asString(title)?.toLowerCase(); + + if (normalizedKind === "execute") { + return "command_execution"; + } + if (normalizedKind === "edit" || normalizedKind === "write") { + return "file_change"; + } + if (normalizedKind === "mcp") { + return "mcp_tool_call"; + } + if (normalizedTitle?.includes("terminal")) { + return "command_execution"; + } + return "dynamic_tool_call"; +} + +function normalizeRequestType(toolCall: unknown): CanonicalRequestType { + const record = asObject(toolCall); + const kind = asString(record?.kind)?.toLowerCase(); + if (kind === "execute") { + return "command_execution_approval"; + } + if (kind === "edit" || kind === "write") { + return "file_change_approval"; + } + return "unknown"; +} + +function selectCursorPermissionOption( + options: ReadonlyArray<{ optionId: string }>, + decision: "acceptForSession" | "accept" | "decline" | "cancel", +): string | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + const allowOnce = options.find((option) => option.optionId === "allow-once"); + const rejectOnce = options.find((option) => option.optionId === "reject-once"); + + if (decision === "acceptForSession") { + return allowAlways?.optionId ?? allowOnce?.optionId; + } + if (decision === "accept") { + return allowOnce?.optionId ?? allowAlways?.optionId; + } + return rejectOnce?.optionId ?? options[0]?.optionId; +} + +function selectCursorAutoApprovalOption( + options: ReadonlyArray<{ optionId: string }>, +): { optionId: string; decision: "acceptForSession" | "accept" } | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + if (allowAlways) { + return { + optionId: allowAlways.optionId, + decision: "acceptForSession", + }; + } + const allowOnce = options.find((option) => option.optionId === "allow-once"); + if (allowOnce) { + return { + optionId: allowOnce.optionId, + decision: "accept", + }; + } + return undefined; +} + +function titleForItemType(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function summarizeToolOutput(rawOutput: unknown): string | undefined { + const output = asObject(rawOutput); + if (!output) return undefined; + + const stdout = asString(output.stdout); + if (stdout && stdout.trim().length > 0) { + return stdout.trim().slice(0, 400); + } + + const summary = JSON.stringify(output); + return summary.length > 400 ? `${summary.slice(0, 397)}...` : summary; +} + +function mapStopReasonToTurnState( + stopReason: string | undefined, +): "completed" | "failed" | "interrupted" | "cancelled" { + if (stopReason === "cancelled") return "cancelled"; + if (stopReason === "interrupted") return "interrupted"; + return "completed"; +} + +function readCursorResumeState(resumeCursor: unknown): CursorResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + + const cursor = resumeCursor as { + acpSessionId?: unknown; + sessionId?: unknown; + }; + + const acpSessionId = + typeof cursor.acpSessionId === "string" + ? cursor.acpSessionId + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + + if (!acpSessionId) { + return {}; + } + return { acpSessionId }; +} + +function writeCursorMessage(context: CursorSessionContext, message: unknown): void { + if (!context.child.stdin.writable) { + throw new Error("Cannot write to Cursor ACP stdin."); + } + context.child.stdin.write(`${JSON.stringify(message)}\n`); +} + +function makeCursorAdapter(options?: CursorAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const spawnCursorAcp = (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + }): ChildProcessWithoutNullStreams => { + if (options?.createProcess) { + return options.createProcess(input); + } + const args = input.model ? ["--model", input.model, "acp"] : ["acp"]; + return spawn(input.binaryPath, args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + }); + }; + + const sendRequest = ( + context: CursorSessionContext, + method: string, + params: unknown, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise => { + const id = context.nextRpcId; + context.nextRpcId += 1; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + context.pending.delete(String(id)); + reject(new Error(`Timed out waiting for ${method}.`)); + }, timeoutMs); + + context.pending.set(String(id), { + method, + timeout, + resolve, + reject, + }); + + writeCursorMessage(context, { + jsonrpc: "2.0", + id, + method, + params, + }); + }); + }; + + const resolvePendingRequest = ( + context: CursorSessionContext, + message: Record, + ) => { + const id = message.id; + if (typeof id !== "string" && typeof id !== "number") { + return; + } + const pending = context.pending.get(String(id)); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + context.pending.delete(String(id)); + + const error = asObject(message.error); + if (error) { + pending.reject(new Error(`${pending.method} failed: ${JSON.stringify(error)}`)); + return; + } + + pending.resolve(message.result); + }; + + const decodePermissionRequest = Schema.decodeUnknownSync(CursorAcpPermissionRequest); + const decodeSessionUpdateNotification = Schema.decodeUnknownSync( + CursorAcpSessionUpdateNotification, + ); + + const emitRuntimeWarning = ( + context: CursorSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), + }); + }); + + const completeTurn = ( + context: CursorSessionContext, + state: "completed" | "failed" | "interrupted" | "cancelled", + errorMessage?: string, + stopReason?: string, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return; + } + + const itemStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: itemStamp.eventId, + provider: PROVIDER, + createdAt: itemStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + providerTurnId: String(turnState.turnId), + providerItemId: turnState.assistantItemId, + }, + }); + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state, + ...(stopReason ? { stopReason } : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + providerTurnId: String(turnState.turnId), + }, + }); + + context.turnState = undefined; + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + ...(errorMessage ? { lastError: errorMessage } : {}), + updatedAt: yield* nowIso, + }; + }); + + const handlePermissionRequest = ( + context: CursorSessionContext, + request: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodePermissionRequest(request); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP permission request.", + error, + ); + } + + return Effect.gen(function* () { + const requestId = ApprovalRequestId.makeUnsafe(randomUUID()); + const requestType = normalizeRequestType(decoded.params.toolCall); + const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId })); + const detail = asString(asObject(decoded.params.toolCall)?.title); + + if (context.runtimeMode === "full-access") { + const selection = + selectCursorAutoApprovalOption(options) ?? + (options[0] + ? { + optionId: options[0].optionId, + decision: "accept", + } + : undefined); + if (!selection) { + return yield* emitRuntimeWarning( + context, + "Cursor ACP permission request contained no selectable options.", + decoded.params, + ); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: decoded.id, + result: { + outcome: { + outcome: "selected", + optionId: selection.optionId, + }, + }, + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision: selection.decision, + resolution: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(decoded.id), + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + }); + return; + } + + context.pendingPermissions.set(requestId, { + jsonRpcId: decoded.id, + requestType, + options, + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + ...(detail ? { detail } : {}), + args: decoded.params, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(decoded.id), + }, + raw: { + source: "cursor.acp.request", + method: decoded.method, + payload: decoded, + }, + }); + }); + }; + + const handleSessionUpdateNotification = ( + context: CursorSessionContext, + notification: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodeSessionUpdateNotification(notification); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP session/update notification.", + error, + ); + } + + return Effect.gen(function* () { + const update = decoded.params.update; + + const base = { + provider: PROVIDER, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), + raw: { + source: "cursor.acp.notification" as const, + method: decoded.method, + messageType: update.sessionUpdate, + payload: decoded, + }, + }; + + switch (update.sessionUpdate) { + case "available_commands_update": { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + payload: { + config: { + availableCommands: update.availableCommands, + }, + }, + }); + return; + } + + case "agent_thought_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: "reasoning_text", + delta: update.content.text, + }, + }); + return; + } + + case "agent_message_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: update.content.text, + }, + }); + return; + } + + case "tool_call": { + if (!context.turnState) return; + const seen = context.turnState.startedToolCalls.has(update.toolCallId); + const itemType = normalizeToolItemType(update.kind, update.title); + const title = update.title ?? titleForItemType(itemType); + context.turnState.toolCalls.set(update.toolCallId, { itemType, title }); + const detail = asString(asObject(update.rawInput)?.command); + + if (!seen) { + context.turnState.startedToolCalls.add(update.toolCallId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.started", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.updated", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + case "tool_call_update": { + if (!context.turnState) return; + const status = update.status === "completed" ? "completed" : "inProgress"; + const trackedTool = context.turnState.toolCalls.get(update.toolCallId); + const itemType = trackedTool?.itemType ?? "dynamic_tool_call"; + const title = trackedTool?.title ?? titleForItemType(itemType); + const stamp = yield* makeEventStamp(); + const eventType = update.status === "completed" ? "item.completed" : "item.updated"; + yield* offerRuntimeEvent({ + ...base, + type: eventType, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status, + title, + ...(summarizeToolOutput(update.rawOutput) + ? { detail: summarizeToolOutput(update.rawOutput) } + : {}), + ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}), + }, + }); + if (update.status === "completed") { + context.turnState.toolCalls.delete(update.toolCallId); + } + return; + } + } + }); + }; + + const handleStdoutLine = (context: CursorSessionContext, line: string): void => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + Effect.runFork( + emitRuntimeWarning(context, "Received invalid JSON from Cursor ACP.", { + line, + }), + ); + return; + } + + const message = asObject(parsed); + if (!message) { + Effect.runFork( + emitRuntimeWarning(context, "Received non-object protocol message from Cursor ACP."), + ); + return; + } + + if (nativeEventLogger) { + try { + const nativeMethod = + typeof message.method === "string" + ? message.method + : typeof message.id === "string" || typeof message.id === "number" + ? "cursor/acp/response" + : "cursor/acp/message"; + const nativeKind = + typeof message.method === "string" && + (typeof message.id === "string" || typeof message.id === "number") + ? "request" + : typeof message.method === "string" + ? "notification" + : "session"; + Effect.runFork( + nativeEventLogger + .write( + { + observedAt: new Date().toISOString(), + event: { + id: EventId.makeUnsafe(randomUUID()), + kind: nativeKind, + provider: PROVIDER, + createdAt: new Date().toISOString(), + method: nativeMethod, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), + payload: message, + }, + }, + null, + ), + ); + } catch { + // Native logging must never block or break protocol handling. + } + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + typeof message.method === "string" + ) { + if (message.method === "session/request_permission") { + Effect.runFork(handlePermissionRequest(context, message)); + return; + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unsupported server request: ${message.method}`, + }, + }); + return; + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + ("result" in message || "error" in message) + ) { + resolvePendingRequest(context, message); + return; + } + + if (typeof message.method === "string") { + if (message.method === "session/update") { + Effect.runFork(handleSessionUpdateNotification(context, message)); + return; + } + + Effect.runFork( + emitRuntimeWarning( + context, + `Unhandled Cursor ACP notification '${message.method}'.`, + message, + ), + ); + return; + } + + Effect.runFork( + emitRuntimeWarning(context, "Received unrecognized protocol message from Cursor ACP.", { + message, + }), + ); + }; + + const stopSessionInternal = ( + context: CursorSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopping) return; + context.stopping = true; + + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Cursor session stopped before request completion.")); + } + context.pending.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped.", "cancelled"); + } + + context.output.close(); + if (!context.child.killed) { + context.child.kill(); + } + + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopping || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const cwd = input.cwd ?? process.cwd(); + const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; + const binaryPath = cursorOptions?.binaryPath ?? "agent"; + const resumeState = readCursorResumeState(input.resumeCursor); + + const child = yield* Effect.try({ + try: () => + spawnCursorAcp({ + binaryPath, + cwd, + env: process.env, + ...(input.model ? { model: input.model } : {}), + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to spawn Cursor ACP process."), + cause, + }), + }); + + const output = readline.createInterface({ input: child.stdout }); + + const session: ProviderSession = { + threadId: input.threadId, + provider: PROVIDER, + status: "connecting", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: CursorSessionContext = { + session, + runtimeMode: input.runtimeMode, + child, + output, + pending: new Map(), + pendingPermissions: new Map(), + turns: [], + turnState: undefined, + acpSessionId: resumeState?.acpSessionId ?? "", + nextRpcId: 1, + stopping: false, + }; + + output.on("line", (line) => { + handleStdoutLine(context, line); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message.length === 0) { + return; + } + Effect.runFork( + emitRuntimeWarning(context, "Cursor ACP stderr output", { + message, + }), + ); + }); + + child.on("error", (error) => { + Effect.runFork( + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + payload: { + message: error.message || "Cursor ACP process error.", + class: "transport_error", + detail: error, + }, + }); + }), + ); + }); + + child.on("exit", (code, signal) => { + if (context.stopping) { + return; + } + Effect.runFork( + Effect.gen(function* () { + if (context.turnState) { + yield* completeTurn( + context, + "failed", + `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + ); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + exitKind: code === 0 ? "graceful" : "error", + recoverable: code === 0, + }, + }); + + sessions.delete(context.session.threadId); + }), + ); + }); + + sessions.set(input.threadId, context); + + const initializeResult = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "initialize", { + protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, + }), + catch: (cause) => toRequestError(input.threadId, "initialize", cause), + }); + const decodedInitialize = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Cursor initialize response did not match expected schema.", + cause, + }), + }); + + const initStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: initStamp.eventId, + provider: PROVIDER, + createdAt: initStamp.createdAt, + threadId: input.threadId, + payload: { + config: decodedInitialize, + }, + raw: { + source: "cursor.acp.response", + method: "initialize", + payload: initializeResult, + }, + }); + + const authenticateRequest = { methodId: "cursor_login" }; + const authStartStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authStartStamp.eventId, + provider: PROVIDER, + createdAt: authStartStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: true, + }, + raw: { + source: "cursor.acp.request", + method: "authenticate", + payload: authenticateRequest, + }, + }); + + const authenticateResult = yield* Effect.tryPromise({ + try: async () => sendRequest(context, "authenticate", authenticateRequest), + catch: (cause) => toRequestError(input.threadId, "authenticate", cause), + }).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ); + const authEndStamp = yield* makeEventStamp(); + if (!authenticateResult.ok) { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + createdAt: authEndStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: false, + error: toMessage(authenticateResult.error, "Cursor authentication failed."), + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: { + error: toMessage(authenticateResult.error, "Cursor authentication failed."), + }, + }, + }); + } else { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + createdAt: authEndStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: false, + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: authenticateResult.value, + }, + }); + } + + const acpSessionId = yield* Effect.tryPromise({ + try: async () => { + if (resumeState?.acpSessionId) { + await sendRequest(context, "session/load", { + sessionId: resumeState.acpSessionId, + cwd, + mcpServers: [], + }); + return resumeState.acpSessionId; + } + + const sessionNewParams: { + cwd: string; + mcpServers: []; + model?: string; + } = { + cwd, + mcpServers: [], + }; + if (input.model) { + sessionNewParams.model = input.model; + } + const result = await sendRequest(context, "session/new", sessionNewParams); + const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); + return decoded.sessionId; + }, + catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause), + }); + + context.acpSessionId = acpSessionId; + context.session = { + ...context.session, + status: "ready", + resumeCursor: { + acpSessionId, + }, + updatedAt: yield* nowIso, + }; + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId: input.threadId, + payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, + }); + + const threadStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: threadStartedStamp.eventId, + provider: PROVIDER, + createdAt: threadStartedStamp.createdAt, + threadId: input.threadId, + payload: { + providerThreadId: acpSessionId, + }, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId: input.threadId, + payload: { + state: "ready", + }, + }); + + return { + ...context.session, + }; + }); + + const sendTurn: CursorAdapterShape["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}'.`, + }); + } + + const promptText = input.input?.trim(); + if (!promptText || promptText.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: CursorTurnState = { + turnId, + assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), + startedToolCalls: new Set(), + toolCalls: new Map(), + items: [], + }; + + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const startedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: startedStamp.eventId, + provider: PROVIDER, + createdAt: startedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const promptResultRaw = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/prompt", { + sessionId: context.acpSessionId, + prompt: [{ type: "text", text: promptText }], + }), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + }); + + const promptResult = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor session/prompt response did not match expected schema.", + cause, + }), + }); + const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason); + yield* completeTurn( + context, + turnStateValue, + turnStateValue === "failed" ? "Cursor prompt failed." : undefined, + promptResult.stopReason, + ); + + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + resumeCursor: { + acpSessionId: context.acpSessionId, + }, + }; + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + if (!context.turnState) { + return; + } + + const cancelResult = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), + catch: (cause) => toRequestError(threadId, "session/cancel", cause), + }).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: () => ({ ok: true as const }), + }), + ); + + if (!cancelResult.ok) { + yield* emitRuntimeWarning( + context, + "Cursor ACP session/cancel is unavailable; marking turn as interrupted.", + cancelResult.error, + ); + } + + yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); + }); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return { + threadId: context.session.threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`, + }), + ); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingPermissions.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const optionId = selectCursorPermissionOption(pending.options, decision); + + if (!optionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `No selectable permission options for request: ${requestId}`, + }); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: pending.jsonRpcId, + result: { + outcome: { + outcome: "selected", + optionId, + }, + }, + }); + + context.pendingPermissions.delete(requestId); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision, + resolution: { + optionId, + }, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(pending.jsonRpcId), + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId, + }, + }, + }); + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopping; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "unsupported", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CursorAdapterShape; + }); +} + +export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); + +export function makeCursorAdapterLive(options?: CursorAdapterLiveOptions) { + return Layer.effect(CursorAdapter, makeCursorAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/GeminiAdapter.ts b/apps/server/src/provider/Layers/GeminiAdapter.ts new file mode 100644 index 0000000000..ed5539fdca --- /dev/null +++ b/apps/server/src/provider/Layers/GeminiAdapter.ts @@ -0,0 +1,643 @@ +import { randomUUID } from "node:crypto"; + +import { + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ChatAttachment, + EventId, + RuntimeItemId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; +import { Effect, FileSystem, Layer, Queue, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { GeminiAdapter, type GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; +import { GeminiCliManager } from "../../geminiCliManager.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; + +const PROVIDER = "gemini" as const; + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function makeEventId(): EventId { + return EventId.makeUnsafe(`gemini_${randomUUID()}`); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function itemIdFromRaw(value: unknown): RuntimeItemId | undefined { + return typeof value === "string" && value.trim().length > 0 + ? RuntimeItemId.makeUnsafe(value) + : undefined; +} + +function mapGeminiPlanStatus(value: unknown): "pending" | "inProgress" | "completed" { + switch (value) { + case "completed": + return "completed"; + case "in_progress": + return "inProgress"; + default: + return "pending"; + } +} + +function mapGeminiToolLifecycleStatus( + value: unknown, +): "inProgress" | "completed" | "failed" | undefined { + switch (value) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "pending": + case "in_progress": + return "inProgress"; + default: + return undefined; + } +} + +function stringFromUnknown(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (value === null || value === undefined) { + return undefined; + } + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +function buildGeminiPromptAttachment(input: { + readonly attachment: ChatAttachment; + readonly stateDir: string; + readonly threadId: ThreadId; + readonly fileSystem: FileSystem.FileSystem; +}): Effect.Effect< + { readonly type: "image"; readonly data: string; readonly mimeType: string }, + ProviderAdapterError +> { + return Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + stateDir: input.stateDir, + attachment: input.attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: `Invalid attachment id '${input.attachment.id}'.`, + }); + } + + const bytes = yield* input.fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: toMessage(cause, "Failed to read Gemini attachment"), + cause: cause instanceof Error ? cause : undefined, + }), + ), + ); + + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: input.attachment.mimeType, + }; + }); +} + +function mapGeminiEventToCanonical(rawEvent: Record): ProviderRuntimeEvent | null { + const method = rawEvent.method; + const threadId = rawEvent.threadId; + const turnId = rawEvent.turnId; + + if (typeof method !== "string" || typeof threadId !== "string") { + return null; + } + + const base: Omit = { + eventId: makeEventId(), + provider: PROVIDER, + threadId: ThreadId.makeUnsafe(threadId), + createdAt: nowIso(), + ...(typeof turnId === "string" && turnId.length > 0 + ? { turnId: TurnId.makeUnsafe(turnId) } + : {}), + }; + + switch (method) { + case "session/started": + return { + ...base, + type: "session.started", + payload: {}, + }; + + case "session/connecting": + return { + ...base, + type: "session.state.changed", + payload: { + state: "starting", + ...(typeof rawEvent.message === "string" ? { reason: rawEvent.message } : {}), + }, + }; + + case "session/ready": + return { + ...base, + type: "session.state.changed", + payload: { + state: "ready", + ...(typeof rawEvent.message === "string" ? { reason: rawEvent.message } : {}), + }, + }; + + case "session/configured": + return { + ...base, + type: "session.configured", + payload: { + config: { + resumeCursor: rawEvent.resumeCursor, + }, + }, + }; + + case "turn/started": + return { + ...base, + type: "turn.started", + payload: typeof rawEvent.model === "string" ? { model: rawEvent.model } : {}, + }; + + case "turn/ended": + if (typeof rawEvent.exitCode !== "number" || rawEvent.exitCode === 0) { + return null; + } + return { + ...base, + type: "turn.completed", + payload: { + state: + typeof rawEvent.exitCode === "number" && rawEvent.exitCode !== 0 ? "failed" : "completed", + ...(typeof rawEvent.stderr === "string" && rawEvent.stderr.trim().length > 0 + ? { errorMessage: rawEvent.stderr.trim() } + : {}), + }, + }; + + case "turn/error": + return { + ...base, + type: "runtime.error", + payload: { + message: + typeof rawEvent.message === "string" && rawEvent.message.trim().length > 0 + ? rawEvent.message.trim() + : "Gemini CLI error", + class: "provider_error", + detail: rawEvent, + }, + }; + + case "gemini/init": + return { + ...base, + type: "session.configured", + payload: { + config: { + ...(typeof rawEvent.session_id === "string" + ? { resumeCursor: { sessionId: rawEvent.session_id } } + : {}), + ...(typeof rawEvent.model === "string" ? { model: rawEvent.model } : {}), + }, + }, + }; + + case "gemini/message": { + const role = rawEvent.role; + const content = rawEvent.content; + + if (role !== "assistant" || typeof content !== "string" || content.length === 0) { + return null; + } + + return { + ...base, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: content, + }, + }; + } + + case "gemini/thought": { + const content = rawEvent.content; + if (typeof content !== "string" || content.trim().length === 0) { + return null; + } + return { + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(`gemini-thought:${turnId ?? threadId}`), + description: content.trim(), + }, + }; + } + + case "gemini/plan": { + const entries = Array.isArray(rawEvent.entries) ? rawEvent.entries : []; + return { + ...base, + type: "turn.plan.updated", + payload: { + plan: entries + .map((entry) => (entry && typeof entry === "object" ? (entry as Record) : null)) + .filter((entry): entry is Record => entry !== null) + .map((entry) => ({ + step: + typeof entry.content === "string" && entry.content.trim().length > 0 + ? entry.content.trim() + : "Gemini plan step", + status: mapGeminiPlanStatus(entry.status), + })), + }, + }; + } + + case "gemini/tool_use": { + const itemId = itemIdFromRaw(rawEvent.tool_id); + return { + ...base, + ...(itemId ? { itemId } : {}), + type: "item.started", + payload: { + itemType: "dynamic_tool_call", + status: "inProgress", + ...(typeof rawEvent.tool_name === "string" ? { title: rawEvent.tool_name } : {}), + ...(rawEvent.parameters !== undefined + ? { detail: JSON.stringify(rawEvent.parameters) } + : {}), + data: rawEvent, + }, + }; + } + + case "gemini/tool_update": { + const itemId = itemIdFromRaw(rawEvent.tool_id); + const detail = + (typeof rawEvent.output === "string" && rawEvent.output.trim().length > 0 + ? rawEvent.output.trim() + : stringFromUnknown(rawEvent.rawOutput)) ?? stringFromUnknown(rawEvent.rawInput); + + return { + ...base, + ...(itemId ? { itemId } : {}), + type: "item.updated", + payload: { + itemType: "dynamic_tool_call", + ...(mapGeminiToolLifecycleStatus(rawEvent.status) + ? { status: mapGeminiToolLifecycleStatus(rawEvent.status) } + : {}), + ...(typeof rawEvent.tool_name === "string" ? { title: rawEvent.tool_name } : {}), + ...(detail ? { detail } : {}), + data: rawEvent, + }, + }; + } + + case "gemini/tool_result": { + const itemId = itemIdFromRaw(rawEvent.tool_id); + const status = rawEvent.status === "failed" || rawEvent.status === "error" ? "failed" : "completed"; + const detail = + typeof rawEvent.output === "string" && rawEvent.output.trim().length > 0 + ? rawEvent.output + : typeof rawEvent.error === "object" && rawEvent.error !== null + ? JSON.stringify(rawEvent.error) + : undefined; + + return { + ...base, + ...(itemId ? { itemId } : {}), + type: "item.completed", + payload: { + itemType: "dynamic_tool_call", + status, + ...(typeof rawEvent.tool_name === "string" ? { title: rawEvent.tool_name } : {}), + ...(detail ? { detail } : {}), + data: rawEvent, + }, + }; + } + + case "gemini/session_info": { + if (typeof rawEvent.title !== "string" || rawEvent.title.trim().length === 0) { + return null; + } + return { + ...base, + type: "thread.metadata.updated", + payload: { + name: rawEvent.title.trim(), + }, + }; + } + + case "gemini/error": + if (rawEvent.severity === "warning") { + return { + ...base, + type: "runtime.warning", + payload: { + message: + typeof rawEvent.message === "string" ? rawEvent.message : "Gemini CLI warning", + detail: rawEvent, + }, + }; + } + + return { + ...base, + type: "runtime.error", + payload: { + message: typeof rawEvent.message === "string" ? rawEvent.message : "Gemini CLI error", + class: "provider_error", + detail: rawEvent, + }, + }; + + case "gemini/result": { + const resultErrorMessage = + typeof rawEvent.error === "object" && + rawEvent.error !== null && + typeof (rawEvent.error as { message?: unknown }).message === "string" + ? (rawEvent.error as { message: string }).message + : undefined; + return { + ...base, + type: "turn.completed", + payload: { + state: rawEvent.status === "error" ? "failed" : "completed", + ...(resultErrorMessage ? { errorMessage: resultErrorMessage } : {}), + ...(rawEvent.stats !== undefined ? { usage: rawEvent.stats } : {}), + }, + }; + } + + default: + return null; + } +} + +const makeGeminiAdapter = () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* Effect.service(ServerConfig); + const eventQueue = yield* Queue.unbounded(); + const manager = new GeminiCliManager(); + + manager.on("event", (rawEvent: Record) => { + const canonical = mapGeminiEventToCanonical(rawEvent); + if (!canonical) { + return; + } + + Effect.runSync(Queue.offer(eventQueue, canonical)); + }); + + const adapter: GeminiAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "restart-session" }, + + startSession: (input) => + Effect.try({ + try: () => { + const cwd = input.cwd ?? process.cwd(); + const model = normalizeModelSlug(input.model, "gemini") ?? getDefaultModel("gemini"); + const resumeCursor = + input.resumeCursor && + typeof input.resumeCursor === "object" && + !Array.isArray(input.resumeCursor) && + typeof (input.resumeCursor as { sessionId?: unknown }).sessionId === "string" + ? { + sessionId: (input.resumeCursor as { sessionId: string }).sessionId, + } + : undefined; + + const context = manager.startSession({ + threadId: String(input.threadId), + model, + cwd, + ...(resumeCursor ? { resumeCursor } : {}), + }); + const now = nowIso(); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode ?? "full-access", + cwd, + model: context.model, + threadId: input.threadId, + createdAt: now, + updatedAt: now, + ...(context.geminiSessionId + ? { resumeCursor: { sessionId: context.geminiSessionId } } + : resumeCursor + ? { resumeCursor } + : {}), + } satisfies ProviderSession; + }, + catch: (cause: unknown) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: toMessage(cause, "Failed to start Gemini session"), + cause: cause instanceof Error ? cause : undefined, + }), + }) as Effect.Effect, + + sendTurn: (input) => + Effect.gen(function* () { + const text = input.input; + if (!text || typeof text !== "string" || text.trim().length === 0) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Turn input must include text.", + }); + } + + const promptAttachments = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + buildGeminiPromptAttachment({ + attachment, + stateDir: serverConfig.stateDir, + threadId: input.threadId, + fileSystem, + }), + { concurrency: 1 }, + ); + + const result = yield* Effect.try({ + try: () => + manager.sendTurn({ + threadId: String(input.threadId), + text, + prompt: [{ type: "text", text }, ...promptAttachments], + ...(input.model ? { model: input.model } : {}), + approvalMode: input.interactionMode === "plan" ? "plan" : "yolo", + }), + catch: (cause: unknown) => { + const message = toMessage(cause, "Failed to send Gemini turn"); + if (message.includes("No Gemini session")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: String(input.threadId), + cause: cause instanceof Error ? cause : undefined, + }); + } + + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: message, + cause: cause instanceof Error ? cause : undefined, + }); + }, + }); + + return { + turnId: TurnId.makeUnsafe(result.turnId), + threadId: input.threadId, + ...(result.resumeCursor ? { resumeCursor: result.resumeCursor } : {}), + } satisfies ProviderTurnStartResult; + }) as Effect.Effect, + + interruptTurn: (threadId, _turnId) => + Effect.try({ + try: () => { + manager.interruptTurn(String(threadId)); + }, + catch: (cause: unknown) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: String(threadId), + detail: toMessage(cause, "Failed to interrupt turn"), + cause: cause instanceof Error ? cause : undefined, + }), + }) as Effect.Effect, + + respondToRequest: (_threadId, _requestId, _decision) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: + "Gemini CLI does not support mid-turn approval requests. Use --approval-mode=yolo.", + }), + ) as Effect.Effect, + + respondToUserInput: (_threadId, _requestId, _answers) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: + "Gemini CLI does not support mid-turn user input requests in headless mode.", + }), + ) as Effect.Effect, + + stopSession: (threadId) => + Effect.sync(() => { + manager.stopSession(String(threadId)); + }), + + listSessions: () => + Effect.sync(() => { + const now = nowIso(); + return manager.listSessions().map((context): ProviderSession => { + const session: ProviderSession = { + provider: PROVIDER, + status: context.status === "stopped" ? "closed" : "ready", + runtimeMode: "full-access", + cwd: context.cwd, + model: context.model, + threadId: ThreadId.makeUnsafe(context.threadId), + createdAt: now, + updatedAt: now, + }; + return context.geminiSessionId + ? Object.assign({}, session, { + resumeCursor: { sessionId: context.geminiSessionId }, + }) + : session; + }); + }), + + hasSession: (threadId) => Effect.sync(() => manager.hasSession(String(threadId))), + + readThread: (_threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "readThread", + detail: "Gemini CLI does not expose thread snapshots in headless mode.", + }), + ) as Effect.Effect, + + rollbackThread: (_threadId, _numTurns) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: "Gemini CLI does not support thread rollback in headless mode.", + }), + ) as Effect.Effect, + + stopAll: () => + Effect.sync(() => { + manager.stopAll(); + }), + + streamEvents: Stream.fromQueue(eventQueue), + }; + + return adapter; + }); + +export const GeminiAdapterLive = Layer.effect(GeminiAdapter, makeGeminiAdapter()); + +export function makeGeminiAdapterLive() { + return Layer.effect(GeminiAdapter, makeGeminiAdapter()); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a50112a62d..0486c20bc0 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,7 +4,10 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { GeminiAdapter, GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,11 +30,67 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeAdapter: ClaudeCodeAdapterShape = { + provider: "claudeCode", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + +const fakeGeminiAdapter: GeminiAdapterShape = { + provider: "gemini", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( ProviderAdapterRegistryLive, - Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), + Layer.succeed(GeminiAdapter, fakeGeminiAdapter), + ), ), NodeServices.layer, ), @@ -42,10 +101,16 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const claude = yield* registry.getByProvider("claudeCode"); + const cursor = yield* registry.getByProvider("cursor"); + const gemini = yield* registry.getByProvider("gemini"); assert.equal(codex, fakeCodexAdapter); + assert.equal(claude, fakeClaudeAdapter); + assert.equal(cursor, fakeCursorAdapter); + assert.equal(gemini, fakeGeminiAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeCode", "cursor", "gemini"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c7e..6b7d58d9ed 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,7 +15,10 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -26,7 +29,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter]; + : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter, yield* GeminiAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691f..d0937f6474 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,212 +1,88 @@ import assert from "node:assert/strict"; -import { it } from "@effect/vitest"; -import { Effect, Layer, Sink, Stream } from "effect"; -import * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; - -// ── Test helpers ──────────────────────────────────────────────────── - -const encoder = new TextEncoder(); - -function mockHandle(result: { stdout: string; stderr: string; code: number }) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout)), - stderr: Stream.make(encoder.encode(result.stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - -function mockSpawnerLayer( - handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); -} - -function failingSpawnerLayer(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} +import { Effect } from "effect"; +import { afterEach, describe, it, vi } from "vitest"; + +import * as CliEnvironment from "../../cliEnvironment"; +import { + checkCodexProviderStatus, + checkGeminiProviderStatus, + parseAuthStatusFromOutput, +} from "./ProviderHealth"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); -// ── Tests ─────────────────────────────────────────────────────────── +describe("ProviderHealth", () => { + it("returns ready when codex CLI is available", async () => { + vi.spyOn(CliEnvironment, "isCodexCliAvailable").mockReturnValue(true); -it.effect("returns ready when codex is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; + const status = await Effect.runPromise(checkCodexProviderStatus); assert.strictEqual(status.provider, "codex"); 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: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); - -it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), -); + }); + + it("returns unavailable when codex CLI is missing", async () => { + vi.spyOn(CliEnvironment, "isCodexCliAvailable").mockReturnValue(false); -it.effect("returns unavailable when codex is below the minimum supported version", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; + const status = await Effect.runPromise(checkCodexProviderStatus); assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.available, false); assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); - -it.effect("returns unauthenticated when auth probe reports login required", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }); -it.effect( - "returns unauthenticated when login status output includes 'not logged in'", - () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); + it("returns ready when gemini CLI is available without forcing auth", async () => { + vi.spyOn(CliEnvironment, "isGeminiCliAvailable").mockReturnValue(true); + vi.stubEnv("GEMINI_API_KEY", ""); -it.effect("returns warning when login status command is unsupported", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "warning"); + const status = await Effect.runPromise(checkGeminiProviderStatus); + assert.strictEqual(status.provider, "gemini"); + assert.strictEqual(status.status, "ready"); assert.strictEqual(status.available, true); assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI authentication status command is unavailable in this Codex version.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); + }); -// ── Pure function tests ───────────────────────────────────────────── + it("marks gemini as authenticated when GEMINI_API_KEY is present", async () => { + vi.spyOn(CliEnvironment, "isGeminiCliAvailable").mockReturnValue(true); + vi.stubEnv("GEMINI_API_KEY", "test-key"); -it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { - const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + const status = await Effect.runPromise(checkGeminiProviderStatus); + assert.strictEqual(status.provider, "gemini"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }); }); -it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"authenticated":false}]\n', - stderr: "", - code: 0, +describe("parseAuthStatusFromOutput", () => { + it("treats exit code 0 with no auth markers as ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("detects authenticated=false in JSON", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); -}); -it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"ok":true}]\n', - stderr: "", - code: 0, + it("returns warning for JSON without auth markers", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); }); - 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 59f41edf81..0fe0416470 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -1,10 +1,12 @@ /** * ProviderHealthLive - Startup-time provider health checks. * - * Performs one-time provider readiness probes when the server starts and + * Performs one-time provider readiness checks when the server starts and * keeps the resulting snapshot in memory for `server.getConfig`. * - * Uses effect's ChildProcessSpawner to run CLI probes natively. + * Startup checks must stay non-interactive. We only verify local CLI + * availability here and defer real authentication/runtime failures to the + * first provider session/turn. * * @module ProviderHealthLive */ @@ -13,20 +15,13 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Result, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Layer } from "effect"; -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "../codexCliVersion"; +import { isCodexCliAvailable, isGeminiCliAvailable } from "../../cliEnvironment"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; -const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; - -// ── Pure helpers ──────────────────────────────────────────────────── +const GEMINI_PROVIDER = "gemini" as const; export interface CommandResult { readonly stdout: string; @@ -40,20 +35,7 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : 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") - ); -} - -function detailFromResult( - result: CommandResult & { readonly timedOut?: boolean }, -): string | undefined { +function detailFromResult(result: CommandResult & { readonly timedOut?: boolean }): string | undefined { if (result.timedOut) return "Timed out while running command."; const stderr = nonEmptyTrimmed(result.stderr); if (stderr) return stderr; @@ -167,154 +149,62 @@ export function parseAuthStatusFromOutput(result: CommandResult): { }; } -// ── Effect-native command execution ───────────────────────────────── - -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - Stream.runFold( - stream, - () => "", - (acc, chunk) => acc + new TextDecoder().decode(chunk), - ); - -const runCodexCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...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< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +export const checkCodexProviderStatus: Effect.Effect = Effect.sync(() => { const checkedAt = new Date().toISOString(); - // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; + if (!isCodexCliAvailable()) { return { provider: CODEX_PROVIDER, status: "error" as const, available: false, authStatus: "unknown" as const, checkedAt, - message: isCommandMissingCause(error) - ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + message: "Codex CLI (`codex`) is not installed or not on PATH.", }; } - if (Option.isNone(versionProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }; - } + return { + provider: CODEX_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + } satisfies ServerProviderStatus; +}); - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }; - } +export const checkGeminiProviderStatus: Effect.Effect = Effect.sync(() => { + const checkedAt = new Date().toISOString(); - const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + if (!isGeminiCliAvailable()) { return { - provider: CODEX_PROVIDER, + provider: GEMINI_PROVIDER, status: "error" as const, available: false, authStatus: "unknown" as const, checkedAt, - message: formatCodexCliUpgradeMessage(parsedVersion), - }; - } - - // Probe 2: `codex login status` — is the user authenticated? - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", + message: "Gemini CLI (`gemini`) is not installed or not on PATH.", }; } - if (Option.isNone(authProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Could not verify Codex authentication status. Timed out while running command.", - }; - } - - const parsed = parseAuthStatusFromOutput(authProbe.success.value); return { - provider: CODEX_PROVIDER, - status: parsed.status, + provider: GEMINI_PROVIDER, + status: "ready" as const, available: true, - authStatus: parsed.authStatus, + authStatus: process.env.GEMINI_API_KEY ? "authenticated" : "unknown", checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; + }; }); -// ── Layer ─────────────────────────────────────────────────────────── - export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatus = yield* checkCodexProviderStatus; + const [codexStatus, geminiStatus] = yield* Effect.all( + [checkCodexProviderStatus, checkGeminiProviderStatus], + { concurrency: 2 }, + ); + return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([codexStatus, geminiStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 63b41d6b06..8e233eccbb 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -42,7 +42,6 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -52,7 +51,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -217,12 +216,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeCode"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -236,10 +238,8 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, - runtimeRepositoryLayer, NodeServices.layer, ), @@ -247,6 +247,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -283,7 +284,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), - Layer.provide(AnalyticsService.layerTest), ); yield* Effect.gen(function* () { @@ -342,7 +342,6 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); const startedSession = yield* Effect.gen(function* () { @@ -356,6 +355,11 @@ it.effect( }); }).pipe(Effect.provide(firstProviderLayer)); + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.stopAll(); + }).pipe(Effect.provide(firstProviderLayer)); + const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: startedSession.threadId }); @@ -380,7 +384,6 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); secondCodex.startSession.mockClear(); @@ -493,6 +496,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeCode"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const startInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { provider?: string; cwd?: string }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -533,6 +559,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeCode"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const startInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { provider?: string; cwd?: string }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -544,7 +593,7 @@ routing.layer("ProviderServiceLive routing", (it) => { runtimeMode: "full-access", }); - yield* routing.codex.stopAll(); + yield* provider.stopAll(); routing.codex.startSession.mockClear(); routing.codex.sendTurn.mockClear(); @@ -573,7 +622,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("lists no sessions after adapter runtime clears", () => + it.effect("stops all sessions and clears adapter runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -588,7 +637,7 @@ routing.layer("ProviderServiceLive routing", (it) => { runtimeMode: "full-access", }); - yield* routing.codex.stopAll(); + yield* provider.stopAll(); const remaining = yield* provider.listSessions(); assert.equal(remaining.length, 0); @@ -636,6 +685,14 @@ routing.layer("ProviderServiceLive routing", (it) => { } } + yield* provider.stopAll(); + const stoppedRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(stoppedRuntime), true); + if (Option.isSome(stoppedRuntime)) { + assert.equal(stoppedRuntime.value.status, "stopped"); + } }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 398a26fb7b..10d16e42ce 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -31,7 +31,6 @@ import { type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -109,7 +108,6 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { - const analytics = yield* Effect.service(AnalyticsService); const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -180,11 +178,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); if (existing) { yield* upsertSessionBinding(existing, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: existing.provider, - strategy: "adopt-existing", - hasResumeCursor: existing.resumeCursor !== undefined, - }); return { adapter, session: existing } as const; } } @@ -213,11 +206,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(resumed, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: resumed.provider, - strategy: "resume-thread", - hasResumeCursor: resumed.resumeCursor !== undefined, - }); return { adapter, session: resumed } as const; }); @@ -274,13 +262,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId); - yield* analytics.record("provider.session.started", { - provider: session.provider, - runtimeMode: input.runtimeMode, - hasResumeCursor: session.resumeCursor !== undefined, - hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, - hasModel: typeof input.model === "string" && input.model.trim().length > 0, - }); return session; }); @@ -320,13 +301,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => lastRuntimeEventAt: new Date().toISOString(), }, }); - yield* analytics.record("provider.turn.sent", { - provider: routed.adapter.provider, - model: input.model, - interactionMode: input.interactionMode, - attachmentCount: input.attachments.length, - hasInput: typeof input.input === "string" && input.input.trim().length > 0, - }); return turn; }); @@ -343,9 +317,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); - yield* analytics.record("provider.turn.interrupted", { - provider: routed.adapter.provider, - }); }); const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => @@ -361,10 +332,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); - yield* analytics.record("provider.request.responded", { - provider: routed.adapter.provider, - decision: input.decision, - }); }); const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) => @@ -398,9 +365,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* routed.adapter.stopSession(routed.threadId); } yield* directory.remove(input.threadId); - yield* analytics.record("provider.session.stopped", { - provider: routed.adapter.provider, - }); }); const listSessions: ProviderServiceShape["listSessions"] = () => @@ -469,13 +433,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); - yield* analytics.record("provider.conversation.rolled_back", { - provider: routed.adapter.provider, - turns: input.numTurns, - }); }); - const runStopAll = () => + const stopAll: ProviderServiceShape["stopAll"] = () => Effect.gen(function* () { const threadIds = yield* directory.listThreadIds(); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); @@ -495,18 +455,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ), ).pipe(Effect.asVoid); - yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, - }); - yield* analytics.flush; }); - yield* Effect.addFinalizer(() => - Effect.catch(runStopAll(), (cause) => - Effect.logWarning("failed to stop provider service", { cause }), - ), - ); - return { startSession, sendTurn, @@ -517,6 +467,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => listSessions, getCapabilities, rollbackConversation, + stopAll, streamEvents: Stream.fromPubSub(runtimeEventPubSub), } satisfies ProviderServiceShape; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0e..694ad05330 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -133,36 +133,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const threadId = ThreadId.makeUnsafe("thread-provider-change"); - - yield* runtimeRepository.upsert({ - threadId, - providerName: "cursor", - adapterKey: "cursor", - runtimeMode: "full-access", - status: "running", - lastSeenAt: new Date().toISOString(), - resumeCursor: null, - runtimePayload: null, - }); - - yield* directory.upsert({ - provider: "codex", - threadId, - }); - - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); - it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); @@ -204,4 +174,46 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("accepts cursor provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + yield* directory.upsert({ + provider: "cursor", + threadId, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "cursor"); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "cursor", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } + })); + + it("accepts persisted gemini provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.makeUnsafe("thread-gemini"); + + yield* directory.upsert({ + provider: "gemini", + threadId, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "gemini"); + + const binding = yield* directory.getBinding(threadId); + assertSome(binding, { + threadId, + provider: "gemini", + }); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439bf..2e3cebbcc4 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -25,7 +25,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor" || providerName === "gemini") { return Effect.succeed(providerName); } return Effect.fail( @@ -95,15 +95,11 @@ const makeProviderSessionDirectory = Effect.gen(function* () { } const now = new Date().toISOString(); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; yield* repository .upsert({ threadId: resolvedThreadId, providerName: binding.provider, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), + adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", status: binding.status ?? existingRuntime?.status ?? "running", lastSeenAt: now, diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts new file mode 100644 index 0000000000..80fb8771d8 --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts @@ -0,0 +1,32 @@ +/** + * 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/CursorAdapter.test.ts b/apps/server/src/provider/Services/CursorAdapter.test.ts new file mode 100644 index 0000000000..007a212e15 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.test.ts @@ -0,0 +1,131 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + CursorAcpPermissionRequest, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "./CursorAdapter.ts"; + +describe("Cursor ACP schemas", () => { + it("decodes session/update thought and message chunks", () => { + const thought = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, + }); + + expect(thought.params.update.sessionUpdate).toBe("agent_thought_chunk"); + + const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, + }); + + expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); + }); + + it("decodes tool call lifecycle updates", () => { + const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { command: "pwd" }, + }, + }, + }); + + expect(started.params.update.sessionUpdate).toBe("tool_call"); + + const completed = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "ok", + stderr: "", + }, + }, + }, + }); + + expect(completed.params.update.sessionUpdate).toBe("tool_call_update"); + }); + + it("decodes permission requests", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpPermissionRequest)({ + jsonrpc: "2.0", + id: 9, + method: "session/request_permission", + params: { + sessionId: "sess-1", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + + expect(decoded.method).toBe("session/request_permission"); + expect(decoded.params.options).toHaveLength(2); + }); + + it("decodes prompt completion result payload", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ + stopReason: "end_turn", + }); + + expect(decoded.stopReason).toBe("end_turn"); + }); + + it("rejects unsupported update types", () => { + expect(() => + Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "unknown_update", + }, + }, + }), + ).toThrow(); + }); +}); diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 0000000000..405ea3689b --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,110 @@ +/** + * CursorAdapter - Cursor ACP implementation of the generic provider adapter contract. + * + * Defines ACP JSON-RPC schemas used by the Cursor adapter layer. + * + * @module CursorAdapter + */ +import { Schema, ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]); +export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type; + +export const CursorAcpTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}); +export type CursorAcpTextContent = typeof CursorAcpTextContent.Type; + +export const CursorAcpSessionUpdate = Schema.Union([ + Schema.Struct({ + sessionUpdate: Schema.Literal("available_commands_update"), + availableCommands: Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + }), + ), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + toolCallId: Schema.String, + title: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + rawInput: Schema.optional(Schema.Unknown), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + toolCallId: Schema.String, + status: Schema.String, + rawOutput: Schema.optional(Schema.Unknown), + }), +]); +export type CursorAcpSessionUpdate = typeof CursorAcpSessionUpdate.Type; + +export const CursorAcpSessionUpdateNotification = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + method: Schema.Literal("session/update"), + params: Schema.Struct({ + sessionId: Schema.String, + update: CursorAcpSessionUpdate, + }), +}); +export type CursorAcpSessionUpdateNotification = typeof CursorAcpSessionUpdateNotification.Type; + +export const CursorAcpPermissionOption = Schema.Struct({ + optionId: Schema.String, + name: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), +}); +export type CursorAcpPermissionOption = typeof CursorAcpPermissionOption.Type; + +export const CursorAcpPermissionRequest = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + id: CursorAcpJsonRpcId, + method: Schema.Literal("session/request_permission"), + params: Schema.Struct({ + sessionId: Schema.String, + toolCall: Schema.optional(Schema.Unknown), + options: Schema.Array(CursorAcpPermissionOption), + }), +}); +export type CursorAcpPermissionRequest = typeof CursorAcpPermissionRequest.Type; + +export const CursorAcpInitializeResult = Schema.Struct({ + protocolVersion: Schema.optional(Schema.Int), + agentCapabilities: Schema.optional(Schema.Unknown), + authMethods: Schema.optional(Schema.Array(Schema.Unknown)), +}); +export type CursorAcpInitializeResult = typeof CursorAcpInitializeResult.Type; + +export const CursorAcpSessionNewResult = Schema.Struct({ + sessionId: Schema.String, + modes: Schema.optional(Schema.Unknown), +}); +export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type; + +export const CursorAcpSessionPromptResult = Schema.Struct({ + stopReason: Schema.optional(Schema.String), +}); +export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type; + +export interface CursorAdapterShape extends ProviderAdapterShape { + readonly provider: "cursor"; +} + +export class CursorAdapter extends ServiceMap.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/provider/Services/GeminiAdapter.ts b/apps/server/src/provider/Services/GeminiAdapter.ts new file mode 100644 index 0000000000..366d69601e --- /dev/null +++ b/apps/server/src/provider/Services/GeminiAdapter.ts @@ -0,0 +1,30 @@ +/** + * GeminiAdapter - Gemini implementation of the generic provider adapter contract. + * + * This service owns Gemini CLI headless process / JSONL streaming semantics + * and emits Gemini provider 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: "gemini"` context. + * + * @module GeminiAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * GeminiAdapterShape - Service API for the Gemini provider adapter. + */ +export interface GeminiAdapterShape extends ProviderAdapterShape { + readonly provider: "gemini"; +} + +/** + * GeminiAdapter - Service tag for Gemini provider adapter operations. + */ +export class GeminiAdapter extends ServiceMap.Service()( + "t3/provider/Services/GeminiAdapter", +) {} diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index ebfe8c8ab1..1d94279a51 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -99,6 +99,11 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Stop all active provider sessions across all adapters. + */ + readonly stopAll: () => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b9..42b384ec96 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -18,7 +18,10 @@ import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/Proj import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; +import { makeGeminiAdapterLive } from "./provider/Layers/GeminiAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -57,8 +60,18 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const cursorAdapterLayer = makeCursorAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const geminiAdapterLayer = makeGeminiAdapterLive(); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), + Layer.provide(cursorAdapterLayer), + Layer.provide(geminiAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index de6b27f429..0abf9f3826 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,15 +1,18 @@ import fs from "node:fs"; import path from "node:path"; -import { Effect, Logger } from "effect"; +import { Effect, Logger, Option } from "effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { - const config = yield* ServerConfig; + const config = yield* Effect.serviceOption(ServerConfig); + if (Option.isNone(config)) { + return Logger.layer([Logger.defaultLogger]); + } - const logDir = path.join(config.stateDir, "logs"); + const logDir = path.join(config.value.stateDir, "logs"); const logPath = path.join(logDir, "server.log"); yield* Effect.sync(() => { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 285028cca6..147116b293 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1172,6 +1172,7 @@ describe("WebSocket Server", () => { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), + stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; const providerLayer = Layer.succeed(ProviderService, providerService); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d8859c2fa5..1237c51d05 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -71,7 +71,6 @@ import { resolveAttachmentPathById, } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; /** @@ -216,8 +215,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings - | Open - | AnalyticsService; + | Open; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -611,6 +609,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const orchestrationEngine = yield* OrchestrationEngineService; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const checkpointDiffQuery = yield* CheckpointDiffQuery; + const liveProviderService = yield* ProviderService; const orchestrationReactor = yield* OrchestrationReactor; const { openInEditor } = yield* Open; @@ -705,6 +704,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path >(); const runPromise = Effect.runPromiseWith(runtimeServices); + yield* Effect.addFinalizer(() => + Effect.catch(liveProviderService.stopAll(), (cause) => + Effect.logWarning("failed to stop provider service", { cause }), + ), + ); const unsubscribeTerminalEvents = yield* terminalManager.subscribe( (event) => void Effect.runPromise(onTerminalEvent(event)), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 5ab5d3c90a..3be5b31398 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { getAppModelOptions, + getCustomModelsForProvider, getSlashModelOptions, normalizeCustomModelSlugs, + patchCustomModelsForProvider, resolveAppServiceTier, shouldShowFastTierIcon, resolveAppModelSelection, @@ -22,6 +24,27 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("normalizes provider-specific aliases for claude and cursor", () => { + expect(normalizeCustomModelSlugs(["sonnet"], "claudeCode")).toEqual([]); + expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeCode")).toEqual([ + "claude/custom-sonnet", + ]); + expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]); + expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([ + "cursor/custom-model", + ]); + }); + + it("supports provider-specific Gemini custom models", () => { + const options = getAppModelOptions("gemini", ["gemini/internal-preview"]); + + expect(options.at(-1)).toEqual({ + slug: "gemini/internal-preview", + name: "gemini/internal-preview", + isCustom: true, + }); + }); }); describe("getAppModelOptions", () => { @@ -47,6 +70,14 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("keeps a saved custom provider model available as an exact slug option", () => { + const options = getAppModelOptions("claudeCode", ["claude/custom-opus"], "claude/custom-opus"); + + expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( + true, + ); + }); }); describe("resolveAppModelSelection", () => { @@ -83,6 +114,14 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); + + it("includes provider-specific custom slugs in non-codex model lists", () => { + const claudeOptions = getAppModelOptions("claudeCode", ["claude/custom-opus"]); + const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]); + + expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); + expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true); + }); }); describe("resolveAppServiceTier", () => { @@ -96,6 +135,28 @@ describe("resolveAppServiceTier", () => { }); }); +describe("provider-specific custom models", () => { + it("reads custom models for the requested provider", () => { + expect( + getCustomModelsForProvider( + { + customCodexModels: ["gpt-custom"], + customClaudeModels: [], + customCursorModels: [], + customGeminiModels: ["gemini-custom"], + }, + "gemini", + ), + ).toEqual(["gemini-custom"]); + }); + + it("patches the correct settings key for Gemini custom models", () => { + expect(patchCustomModelsForProvider("gemini", ["gemini-custom"])).toEqual({ + customGeminiModels: ["gemini-custom"], + }); + }); +}); + describe("shouldShowFastTierIcon", () => { it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => { expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a6..f2c1fbe7a9 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,33 +1,16 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { type ProviderKind } from "@t3tools/contracts"; +import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const APP_SERVICE_TIER_OPTIONS = [ - { - value: "auto", - label: "Automatic", - description: "Use Codex defaults without forcing a service tier.", - }, - { - value: "fast", - label: "Fast", - description: "Request the fast service tier when the model supports it.", - }, - { - value: "flex", - label: "Flex", - description: "Request the flex service tier when the model supports it.", - }, -] as const; -export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; -const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); -const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), + cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), + gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -41,10 +24,18 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), - codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), customCodexModels: 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([])), + ), + customGeminiModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -53,22 +44,6 @@ export interface AppModelOption { isCustom: boolean; } -export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { - return serviceTier === "auto" ? null : serviceTier; -} - -export function shouldShowFastTierIcon( - model: string | null | undefined, - serviceTier: AppServiceTier, -): boolean { - const normalizedModel = normalizeModelSlug(model); - return ( - resolveAppServiceTier(serviceTier) === "fast" && - normalizedModel !== null && - MODELS_WITH_FAST_SUPPORT.has(normalizedModel) - ); -} - const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let listeners: Array<() => void> = []; @@ -108,9 +83,43 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), + customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), + customGeminiModels: normalizeCustomModelSlugs(settings.customGeminiModels, "gemini"), }; } +export function getCustomModelsForProvider( + settings: Pick, + provider: ProviderKind, +): readonly string[] { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "cursor": + return settings.customCursorModels; + case "gemini": + return settings.customGeminiModels; + case "codex": + default: + return settings.customCodexModels; + } +} + +export function patchCustomModelsForProvider(provider: ProviderKind, models: string[]) { + switch (provider) { + case "claudeCode": + return { customClaudeModels: models } satisfies Partial; + case "cursor": + return { customCursorModels: models } satisfies Partial; + case "gemini": + return { customGeminiModels: models } satisfies Partial; + case "codex": + default: + return { customCodexModels: models } satisfies Partial; + } +} + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -148,38 +157,6 @@ export function getAppModelOptions( return options; } -export function resolveAppModelSelection( - provider: ProviderKind, - customModels: readonly string[], - selectedModel: string | null | undefined, -): string { - const options = getAppModelOptions(provider, customModels, selectedModel); - const trimmedSelectedModel = selectedModel?.trim(); - if (trimmedSelectedModel) { - const direct = options.find((option) => option.slug === trimmedSelectedModel); - if (direct) { - return direct.slug; - } - - const byName = options.find( - (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), - ); - if (byName) { - return byName.slug; - } - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (!normalizedSelectedModel) { - return getDefaultModel(provider); - } - - return ( - options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? - getDefaultModel(provider) - ); -} - export function getSlashModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -264,6 +241,36 @@ function subscribe(listener: () => void): () => void { }; } +export type ServiceTierOverride = "auto" | "fast" | "flex"; +export type AppServiceTier = ServiceTierOverride; + +export function resolveAppServiceTier( + tier: ServiceTierOverride, +): "fast" | "flex" | null { + return tier === "auto" ? null : tier; +} + +export function shouldShowFastTierIcon( + model: string, + tier: ServiceTierOverride, +): boolean { + return model === "gpt-5.4" && tier === "fast"; +} + +export function resolveAppModelSelection( + provider: ProviderKind, + customModels: readonly string[], + selectedModel: string, +): string { + if (selectedModel) { + const options = getAppModelOptions(provider, customModels, selectedModel); + if (options.some((o) => o.slug === selectedModel)) { + return selectedModel; + } + } + return getModelOptions(provider)[0]?.slug ?? ""; +} + export function useAppSettings() { const settings = useSyncExternalStore( subscribe, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f4199..cd3428a988 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,10 +1,12 @@ import { type ApprovalRequestId, - DEFAULT_MODEL_BY_PROVIDER, + CURSOR_REASONING_OPTIONS, + DEFAULT_MODEL, EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, + type CursorReasoningOption, type MessageId, type ProjectId, type ProjectEntry, @@ -25,8 +27,12 @@ import { import { getDefaultModel, getDefaultReasoningEffort, + getCursorModelCapabilities, + getCursorModelFamilyOptions, getReasoningEffortOptions, normalizeModelSlug, + parseCursorModelSelection, + resolveCursorModelFromSelection, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { @@ -72,7 +78,6 @@ import { findLatestProposedPlan, type PendingApproval, type PendingUserInput, - type ProviderPickerKind, PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, @@ -93,7 +98,6 @@ import { buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, proposedPlanTitle, - resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { @@ -123,7 +127,7 @@ import { } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, @@ -135,13 +139,13 @@ import { DiffIcon, EllipsisIcon, FolderClosedIcon, + InfoIcon, LockIcon, LockOpenIcon, Undo2Icon, XIcon, CopyIcon, CheckIcon, - ZapIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -200,10 +204,8 @@ import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, + getCustomModelsForProvider, resolveAppModelSelection, - resolveAppServiceTier, - shouldShowFastTierIcon, - type AppServiceTier, useAppSettings, } from "../appSettings"; import { @@ -420,7 +422,6 @@ type ComposerCommandItem = model: ModelSlug; label: string; description: string; - showFastBadge: boolean; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; @@ -530,12 +531,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { model ) : null} - - {props.item.type === "model" && props.item.showFastBadge ? ( - - ) : null} - {props.item.label} - + {props.item.label} {props.item.description} ); @@ -613,9 +609,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); - const setComposerDraftInteractionMode = useComposerDraftStore( - (store) => store.setInteractionMode, - ); + const setComposerDraftInteractionMode = useComposerDraftStore((store) => store.setInteractionMode); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); @@ -744,15 +738,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + fallbackDraftProject?.model ?? DEFAULT_MODEL, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; - const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; @@ -793,28 +786,46 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.messages.length > 0 || activeThread.session !== null), ); - const selectedServiceTierSetting = settings.codexServiceTier; - const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const cursorModelSelectionLockedReason = + hasThreadStarted && selectedProvider === "cursor" + ? "Cursor currently does not support changing models after the first message in a thread." + : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + activeThread?.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider) ?? + DEFAULT_MODEL, ); - const customModelsForSelectedProvider = settings.customCodexModels; + const _customModelsForSelectedProvider = getCustomModelsForProvider(settings, selectedProvider); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } - return resolveAppModelSelection( - selectedProvider, - customModelsForSelectedProvider, - draftModel, - ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + + const providerOptions = getCustomModelOptionsByProvider(settings)[selectedProvider]; + const directMatch = providerOptions.find((option) => option.slug === draftModel); + if (directMatch) { + return directMatch.slug as ModelSlug; + } + + const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider); + if (normalizedDraftModel) { + const normalizedMatch = providerOptions.find( + (option) => option.slug === normalizedDraftModel, + ); + if (normalizedMatch) { + return normalizedMatch.slug as ModelSlug; + } + } + + return resolveModelSlugForProvider(selectedProvider, draftModel); + }, [baseThreadModel, composerDraft.model, selectedProvider, settings]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); @@ -830,21 +841,46 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); - const selectedModelForPicker = selectedModel; + const selectedCursorModel = useMemo( + () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), + [selectedModel, selectedProvider], + ); + const selectedCursorModelCapabilities = useMemo( + () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null), + [selectedCursorModel], + ); + const hasSelectedCursorTraits = Boolean( + selectedCursorModelCapabilities && + (selectedCursorModelCapabilities.supportsReasoning || + selectedCursorModelCapabilities.supportsFast || + selectedCursorModelCapabilities.supportsThinking), + ); + const selectedModelForPicker = + selectedProvider === "cursor" && selectedCursorModel + ? selectedCursorModel.family + : selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), [settings], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; + if (selectedProvider !== "cursor") { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + } + + const currentOptions = modelOptionsByProvider.cursor; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + : selectedModelForPicker; }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, + PROVIDER_OPTIONS.filter( + (option) => + option.available && (lockedProvider === null || option.value === lockedProvider), ).flatMap((option) => modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, @@ -937,11 +973,9 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled && activeProposedPlan !== null; const activePendingApproval = pendingApprovals[0] ?? null; - const isComposerApprovalState = activePendingApproval !== null; + const _isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = - isComposerApprovalState || - pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); + pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); useEffect(() => { if (!activePendingProgress) { return; @@ -1251,10 +1285,8 @@ export default function ChatView({ threadId }: ChatViewProps) { model: slug, label: name, description: `${providerLabel} · ${slug}`, - showFastBadge: - provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting), })); - }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -1648,9 +1680,6 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId, ], ); - const toggleInteractionMode = useCallback(() => { - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }, [handleInteractionModeChange, interactionMode]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2104,14 +2133,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (phase !== "running") return; + if (!isWorking) return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [phase]); + }, [isWorking]); const beginSendPhase = useCallback((nextPhase: Exclude) => { setSendStartedAt((current) => current ?? new Date().toISOString()); @@ -2129,14 +2158,14 @@ export default function ChatView({ threadId }: ChatViewProps) { } if ( phase === "running" || - activePendingApproval !== null || + pendingApprovals.length > 0 || activePendingUserInput !== null || activeThread?.error ) { resetSendPhase(); } }, [ - activePendingApproval, + pendingApprovals, activePendingUserInput, activeThread?.error, phase, @@ -2398,25 +2427,31 @@ export default function ChatView({ threadId }: ChatViewProps) { const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) { + return; + } if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; } + if (phase === "running") { + return; + } const trimmed = prompt.trim(); if (showPlanFollowUpPrompt && activeProposedPlan) { - const followUp = resolvePlanFollowUpSubmission({ - draftText: trimmed, - planMarkdown: activeProposedPlan.planMarkdown, - }); + const followUpText = + trimmed.length > 0 + ? trimmed + : buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); + const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); await onSubmitPlanFollowUp({ - text: followUp.text, - interactionMode: followUp.interactionMode, + text: followUpText, + interactionMode: nextInteractionMode, }); return; } @@ -2544,7 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2622,11 +2657,10 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, - serviceTier: selectedServiceTier, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - provider: selectedProvider, + ...(isFirstMessage ? { provider: selectedProvider } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, @@ -2884,10 +2918,6 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: nextInteractionMode, }); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); - await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2898,7 +2928,6 @@ export default function ChatView({ threadId }: ChatViewProps) { text: trimmed, attachments: [], }, - provider: selectedProvider, model: selectedModel || undefined, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } @@ -2933,8 +2962,6 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, - selectedProvider, - setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, ], @@ -2946,11 +2973,11 @@ export default function ChatView({ threadId }: ChatViewProps) { !api || !activeThread || !activeProject || - !activeProposedPlan || - !isServerThread || - isSendBusy || - isConnecting || - sendInFlightRef.current + !activeProposedPlan || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current ) { return; } @@ -2964,7 +2991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel || (activeThread.model as ModelSlug) || (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + DEFAULT_MODEL; sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -3060,6 +3087,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; + if (cursorModelSelectionLockedReason !== null && provider === "cursor") { + scheduleComposerFocus(); + return; + } if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; @@ -3067,19 +3098,62 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection(provider, getCustomModelsForProvider(settings, provider), model), ); scheduleComposerFocus(); }, [ activeThread, + cursorModelSelectionLockedReason, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, + settings, ], ); + const onCursorReasoningSelect = useCallback( + (reasoning: CursorReasoningOption) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning, + fast: cursorSelection.fast, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorFastModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: enabled, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorThinkingModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: cursorSelection.fast, + thinking: enabled, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); @@ -3285,11 +3359,6 @@ export default function ChatView({ threadId }: ChatViewProps) { key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => { - if (key === "Tab" && event.shiftKey) { - toggleInteractionMode(); - return true; - } - const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; @@ -3391,7 +3460,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} isGitRepo={isGitRepo} - openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} activeProjectScripts={activeProject?.scripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null @@ -3413,6 +3481,11 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} + {/* Messages */} @@ -3464,7 +3537,7 @@ export default function ChatView({ threadId }: ChatViewProps) { data-chat-composer-form="true" >
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( + {pendingUserInputs.length > 0 ? (
- {composerMenuOpen && !isComposerApprovalState && ( + {composerMenuOpen && (
)} - {!isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && ( + {pendingUserInputs.length === 0 && composerImages.length > 0 && (
{composerImages.map((image) => (
{/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} +
+
+ {/* Provider/model picker */} + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + )} - {selectedProvider === "codex" && selectedEffort != null ? ( - <> + {selectedProvider === "cursor" ? ( + <> + {hasSelectedCursorTraits && ( - - - ) : null} + )} - {/* Divider */} - + {selectedCursorModel && + selectedCursorModelCapabilities && + hasSelectedCursorTraits && ( + <> + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + + )} + + ) : selectedProvider === "codex" && selectedEffort != null ? ( + <> + + + + ) : null} + + {/* Divider */} + + + {/* Interaction mode toggle */} + + + {/* Divider */} + + + {/* Runtime mode toggle */} + +
- {/* Interaction mode toggle */} - - - {/* Divider */} - - - {/* Runtime mode toggle */} - + ) : null} + +
+ ) : phase === "running" ? ( + -
- - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - Preparing worktree... - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} + + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( + ) : ( +
- ) : ( -
- - - - } - > - - - - + void onImplementPlanInNewThread()} - > - Implement in new thread - - - -
- ) - ) : ( - + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
) - ) : null} -
+ ) : ( + + ) + ) : null}
- )} +
@@ -3972,7 +4090,6 @@ interface ChatHeaderProps { activeThreadTitle: string; activeProjectName: string | undefined; isGitRepo: boolean; - openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; @@ -3991,7 +4108,6 @@ const ChatHeader = memo(function ChatHeader({ activeThreadTitle, activeProjectName, isGitRepo, - openInCwd, activeProjectScripts, preferredScriptId, keybindings, @@ -4040,7 +4156,7 @@ const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && } @@ -4116,84 +4232,80 @@ const ProviderHealthBanner = memo(function ProviderHealthBanner({ ); }); -interface ComposerPendingApprovalPanelProps { - approval: PendingApproval; - pendingCount: number; -} - -const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({ - approval, - pendingCount, -}: ComposerPendingApprovalPanelProps) { - const approvalSummary = - approval.requestKind === "command" - ? "Command approval requested" - : approval.requestKind === "file-read" - ? "File-read approval requested" - : "File-change approval requested"; - - return ( -
-
- PENDING APPROVAL - {approvalSummary} - {pendingCount > 1 ? ( - 1/{pendingCount} - ) : null} -
-
- ); -}); - -interface ComposerPendingApprovalActionsProps { - requestId: ApprovalRequestId; - isResponding: boolean; +interface PendingApprovalsPanelProps { + pendingApprovals: PendingApproval[]; + respondingRequestIds: ApprovalRequestId[]; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise; } -const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ - requestId, - isResponding, +const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ + pendingApprovals, + respondingRequestIds, onRespondToApproval, -}: ComposerPendingApprovalActionsProps) { +}: PendingApprovalsPanelProps) { + if (pendingApprovals.length === 0) return null; return ( - <> - - - - - +
+ {pendingApprovals.map((approval) => { + const isResponding = respondingRequestIds.includes(approval.requestId); + + return ( + + + + {approval.requestKind === "command" + ? "Command approval requested" + : approval.requestKind === "file-read" + ? "File-read approval requested" + : "File-change approval requested"} + + + {approval.detail} + + + + + + + + + ); + })} +
); }); @@ -4731,6 +4843,7 @@ type TimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; + function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); return 120 + Math.min(estimatedLines * 22, 880); @@ -4760,6 +4873,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); + const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< + Record + >({}); + const onToggleAllDirectories = useCallback((turnId: TurnId) => { + setAllDirectoriesExpandedByTurnId((current) => ({ + ...current, + [turnId]: !(current[turnId] ?? true), + })); + }, []); useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -4796,21 +4918,6 @@ const MessagesTimeline = memo(function MessagesTimeline({ } if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; continue; } @@ -4899,7 +5006,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); - if (row.kind === "working") return 40; + if (row.kind === "working") return 132; return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, measureElement: measureVirtualElement, @@ -4940,15 +5047,6 @@ const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); const renderRowContent = (row: TimelineRow) => (
- +
@@ -5280,31 +5377,46 @@ const MessagesTimeline = memo(function MessagesTimeline({ ); }); -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { +function isAvailableProviderOption( + option: (typeof PROVIDER_OPTIONS)[number], +): option is { value: ProviderKind; label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +const _UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, ] as const; function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; + customGeminiModels: readonly string[]; }): Record> { + const cursorFamilyOptions = getCursorModelFamilyOptions(); return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), + cursor: [ + ...cursorFamilyOptions, + ...getAppModelOptions("cursor", settings.customCursorModels).filter( + (option) => + option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug), + ), + ], + gemini: getAppModelOptions("gemini", settings.customGeminiModels), }; } -const PROVIDER_ICON_BY_PROVIDER: Record = { +const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, + gemini: Gemini, claudeCode: ClaudeAI, cursor: CursorIcon, }; @@ -5339,6 +5451,10 @@ function resolveModelForProviderPicker( return resolved.slug; } + if (provider === "cursor") { + return parseCursorModelSelection(normalized).family; + } + return null; } @@ -5347,7 +5463,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; - serviceTierSetting: AppServiceTier; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { @@ -5379,10 +5494,13 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { } > - @@ -5425,10 +5543,6 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { value={modelOption.slug} onClick={() => setIsMenuOpen(false)} > - {option.value === "codex" && - shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? ( - - ) : null} {modelOption.name} ))} @@ -5438,26 +5552,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( @@ -5554,14 +5649,125 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { ); }); +const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { + selection: ReturnType; + capabilities: ReturnType; + disabled?: boolean; + onReasoningChange: (reasoning: CursorReasoningOption) => void; + onFastModeChange: (enabled: boolean) => void; + onThinkingModeChange: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const reasoningLabelByOption: Record = { + low: "Low", + normal: "Normal", + high: "High", + xhigh: "Extra High", + }; + const traitSummary = [ + ...(props.capabilities.supportsReasoning + ? [reasoningLabelByOption[props.selection.reasoning]] + : []), + ...(props.capabilities.supportsFast && props.selection.fast ? ["Fast"] : []), + ...(props.capabilities.supportsThinking && props.selection.thinking ? ["Thinking"] : []), + ]; + const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits"; + + return ( + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel} + + + {props.capabilities.supportsReasoning && ( + +
Reasoning
+ { + if (props.disabled) return; + if (!value) return; + const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value); + if (!nextReasoning) return; + props.onReasoningChange(nextReasoning); + }} + > + {CURSOR_REASONING_OPTIONS.map((reasoning) => ( + + {reasoning} + {reasoning === "normal" ? " (default)" : ""} + + ))} + +
+ )} + {props.capabilities.supportsReasoning && + (props.capabilities.supportsFast || props.capabilities.supportsThinking) && ( + + )} + {props.capabilities.supportsFast && ( + +
Fast Mode
+ { + if (props.disabled) return; + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+ )} + {props.capabilities.supportsFast && props.capabilities.supportsThinking && } + {props.capabilities.supportsThinking && ( + +
Thinking
+ { + if (props.disabled) return; + props.onThinkingModeChange(value === "on"); + }} + > + off + on + +
+ )} +
+
+ ); +}); + const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, - openInCwd, + activeThreadId, }: { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; - openInCwd: string | null; + activeThreadId: ThreadId | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); @@ -5607,17 +5813,26 @@ const OpenInPicker = memo(function OpenInPicker({ : (options[0]?.value ?? null); const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + const activeThread = useStore((store) => + activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + ); + const activeProjectId = activeThread?.projectId ?? null; + const activeProject = useStore((store) => + activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + ); + const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); - if (!api || !openInCwd) return; + if (!api || !activeProject) return; const editor = editorId ?? effectiveEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, editor); localStorage.setItem(LAST_EDITOR_KEY, editor); setLastEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [activeProject, activeThread, effectiveEditor, setLastEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -5629,22 +5844,23 @@ const OpenInPicker = memo(function OpenInPicker({ const handler = (e: globalThis.KeyboardEvent) => { const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; + if (!api || !activeProject) return; if (!effectiveEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, effectiveEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [activeProject, activeThread, keybindings, effectiveEditor]); return (
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index f133d377f2..ce0113058a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -394,15 +394,15 @@ describe("composerDraftStore setProvider", () => { it("persists provider-only selection even when prompt/model are empty", () => { const store = useComposerDraftStore.getState(); - store.setProvider(threadId, "codex"); + store.setProvider(threadId, "cursor"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex"); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("cursor"); }); it("removes empty provider-only draft when provider is reset", () => { const store = useComposerDraftStore.getState(); - store.setProvider(threadId, "codex"); + store.setProvider(threadId, "cursor"); store.setProvider(threadId, null); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed3..36e667f2a8 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "claudeCode" || value === "cursor" || value === "gemini" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -809,9 +809,10 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const normalizedModel = normalizeModelSlug(model) ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; + const normalizedModel = + normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; if (!existing && normalizedModel === null) { return state; } diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index b8431bdbcc..02e4e78aed 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -5,7 +5,6 @@ import { buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, proposedPlanTitle, - resolvePlanFollowUpSubmission, } from "./proposedPlan"; describe("proposedPlanTitle", () => { @@ -26,32 +25,6 @@ describe("buildPlanImplementationPrompt", () => { }); }); -describe("resolvePlanFollowUpSubmission", () => { - it("switches to default mode when implementing the ready plan without extra text", () => { - expect( - resolvePlanFollowUpSubmission({ - draftText: " ", - planMarkdown: "## Ship it\n\n- step 1\n", - }), - ).toEqual({ - text: "PLEASE IMPLEMENT THIS PLAN:\n## Ship it\n\n- step 1", - interactionMode: "default", - }); - }); - - it("stays in plan mode when the user adds a follow-up prompt", () => { - expect( - resolvePlanFollowUpSubmission({ - draftText: "Refine step 2 first", - planMarkdown: "## Ship it\n\n- step 1\n", - }), - ).toEqual({ - text: "Refine step 2 first", - interactionMode: "plan", - }); - }); -}); - describe("buildPlanImplementationThreadTitle", () => { it("uses the plan heading when building the implementation thread title", () => { expect(buildPlanImplementationThreadTitle("# Integrate RPC\n\nBody")).toBe( diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 1550eb7de1..fdfe859726 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -16,27 +16,6 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string { return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; } -export function resolvePlanFollowUpSubmission(input: { - draftText: string; - planMarkdown: string; -}): { - text: string; - interactionMode: "default" | "plan"; -} { - const trimmedDraftText = input.draftText.trim(); - if (trimmedDraftText.length > 0) { - return { - text: trimmedDraftText, - interactionMode: "plan", - }; - } - - return { - text: buildPlanImplementationPrompt(input.planMarkdown), - interactionMode: "default", - }; -} - export function buildPlanImplementationThreadTitle(planMarkdown: string): string { const title = proposedPlanTitle(planMarkdown); if (!title) { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a272..23936efcb0 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,12 +3,11 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { ZapIcon } from "lucide-react"; import { - APP_SERVICE_TIER_OPTIONS, MAX_CUSTOM_MODEL_LENGTH, - shouldShowFastTierIcon, + getCustomModelsForProvider, + patchCustomModelsForProvider, useAppSettings, } from "../appSettings"; import { isElectron } from "../env"; @@ -18,7 +17,6 @@ import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -54,37 +52,29 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, + { + provider: "cursor", + title: "Cursor", + description: "Save additional Cursor model slugs for the picker and `/model` command.", + placeholder: "your-cursor-model-slug", + example: "openai/gpt-oss-120b", + }, + { + provider: "gemini", + title: "Gemini", + description: "Save additional Gemini model slugs for the picker and `/model` command.", + placeholder: "your-gemini-model-slug", + example: "gemini-3.1-pro-preview-customtools", + }, ] as const; -function getCustomModelsForProvider( - settings: ReturnType["settings"], - provider: ProviderKind, -) { - switch (provider) { - case "codex": - default: - return settings.customCodexModels; - } -} - -function getDefaultCustomModelsForProvider( - defaults: ReturnType["defaults"], - provider: ProviderKind, -) { - switch (provider) { - case "codex": - default: - return defaults.customCodexModels; - } -} - -function patchCustomModels(provider: ProviderKind, models: string[]) { - switch (provider) { - case "codex": - default: - return { customCodexModels: models }; - } -} function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); @@ -96,6 +86,9 @@ function SettingsRouteView() { Record >({ codex: "", + claudeCode: "", + cursor: "", + gemini: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -103,7 +96,6 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; - const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -156,7 +148,7 @@ function SettingsRouteView() { return; } - updateSettings(patchCustomModels(provider, [...customModels, normalized])); + updateSettings(patchCustomModelsForProvider(provider, [...customModels, normalized])); setCustomModelInputByProvider((existing) => ({ ...existing, [provider]: "", @@ -170,7 +162,9 @@ function SettingsRouteView() { const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { const customModels = getCustomModelsForProvider(settings, provider); - updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug))); + updateSettings( + patchCustomModelsForProvider(provider, customModels.filter((model) => model !== slug)), + ); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, @@ -310,43 +304,6 @@ function SettingsRouteView() {
- - {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { const provider = providerSettings.provider; const customModels = getCustomModelsForProvider(settings, provider); @@ -426,9 +383,9 @@ function SettingsRouteView() { variant="outline" onClick={() => updateSettings( - patchCustomModels( + patchCustomModelsForProvider( provider, - [...getDefaultCustomModelsForProvider(defaults, provider)], + [...getCustomModelsForProvider(defaults, provider)], ), ) } @@ -445,14 +402,9 @@ function SettingsRouteView() { key={`${provider}:${slug}`} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2" > -
- {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? ( - - ) : null} - - {slug} - -
+ + {slug} +