diff --git a/.plans/17-claude-code.md b/.plans/17-claude-code.md index 1349b8267e..822e978f50 100644 --- a/.plans/17-claude-code.md +++ b/.plans/17-claude-code.md @@ -15,12 +15,12 @@ Claude integration must plug into this path instead of reintroducing legacy prov --- -## Current constraints to design around +## Current constraints to design around (post-Stage 1) 1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. -2. `ProviderService.startSession` currently defaults to `provider: "codex"` unless explicitly provided. -3. `thread.turn.start` has no provider field today, so first-turn provider selection cannot be explicitly requested. -4. `ProviderService` requires adapter `startSession()` to return a `ProviderSession` with `threadId`. +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. @@ -61,21 +61,24 @@ Update `packages/contracts/src/orchestration.ts`: This removes the implicit “Codex unless session already exists” behavior as the only path. -### 1.3 Provider session start input for Claude runtime knobs +### 1.3 Provider session start input for Claude runtime knobs (completed) Update `packages/contracts/src/provider.ts`: -1. Extend `ProviderSessionStartInput` with optional Claude-specific fields (for example `claudeBinaryPath`, `permissionMode`, `maxThinkingTokens`). -2. Keep fields optional so current call sites remain valid. -3. Continue using generic `resumeThreadId` + `resumeCursor` as the cross-provider recovery mechanism. +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 +### 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. Backward compatibility of existing command/schema decoding. +3. Breaking-change expectations for removed top-level provider fields. --- @@ -100,9 +103,9 @@ Baseline adapter options to support from day one: 1. `cwd` 2. `model` -3. `pathToClaudeCodeExecutable` (from `claudeBinaryPath`) -4. `permissionMode` -5. `maxThinkingTokens` +3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) +4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) +5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) 6. `resume` 7. `resumeSessionAt` 8. `includePartialMessages` @@ -120,7 +123,7 @@ Required capabilities: 2. Multi-turn input queue. 3. Interrupt support. 4. Approval request/response bridge. -5. Resume support via `resumeThreadId` / `resumeCursor`. +5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). #### 2.2.a Agent SDK details to preserve @@ -129,7 +132,7 @@ 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`. +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 @@ -142,21 +145,23 @@ 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(input.sessionId), + prompt: makePromptAsyncIterable(), options: { cwd: input.cwd, model: input.model, - permissionMode: input.permissionMode, - maxThinkingTokens: input.maxThinkingTokens, - pathToClaudeCodeExecutable: input.claudeBinaryPath, - resume: input.resumeThreadId, - resumeSessionAt: readResumeCursor(input.resumeCursor), + permissionMode: claudeOptions?.permissionMode, + maxThinkingTokens: claudeOptions?.maxThinkingTokens, + pathToClaudeCodeExecutable: claudeOptions?.binaryPath, + resume: resumeState?.threadId, + resumeSessionAt: resumeState?.sessionAt, signal: abortController.signal, includePartialMessages: true, - canUseTool: makeCanUseTool(input.sessionId), - hooks: makeClaudeHooks(input.sessionId), + canUseTool: makeCanUseTool(), + hooks: makeClaudeHooks(), }, }); return { abortController, result }; @@ -193,354 +198,244 @@ const sdkMessageStream = Stream.fromAsyncIterable( Portable fallback (already aligned with current server patterns): ```ts -const queue = yield* Queue.unbounded(); -yield* Effect.forkScoped( - Effect.tryPromise({ - try: async () => { +const sdkMessageStream = Stream.async((emit) => { + let cancelled = false; + void (async () => { + try { for await (const message of session.result) { - Queue.offerAllUnsafe(queue, [message]); + if (cancelled) break; + emit.single(message); } - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream pump failed.", - cause, - }), - }), -); -const sdkMessageStream = Stream.fromQueue(queue); -``` - -#### 2.2.d Multi-turn prompt queue pattern (Effect) - -Use an Effect queue as the single input boundary: - -```ts -const promptQueue = yield* Queue.unbounded(); - -const prompt: AsyncIterable = { - [Symbol.asyncIterator]() { - return { - next: async () => { - const item = await Effect.runPromise(Queue.take(promptQueue)); - if (item.type === "terminate") { - return { done: true, value: undefined }; - } - return { done: false, value: item }; - }, - }; - }, -}; + emit.end(); + } catch (cause) { + emit.fail( + new ProviderAdapterProcessError({ + provider: "claudeCode", + sessionId, + detail: "Claude runtime stream failed.", + cause, + }), + ); + } + })(); + return Effect.sync(() => { + cancelled = true; + }); +}); ``` -`sendTurn()` enqueues a user envelope, while `stopSession()` enqueues terminate and aborts. +### 2.3 Canonical event mapping -#### 2.2.e Approval bridge with `canUseTool` +Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. -Map SDK approval checks to existing orchestration approval flows: +Initial mapping target: -```ts -const canUseTool = async (toolName: string, toolInput: Record) => { - if (approvalModeIsFullAccess(sessionId)) { - return { behavior: "allow", updatedInput: toolInput }; - } - - const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); - emitRuntimeEvent({ - type: "approval.requested", - provider: "claudeCode", - sessionId, - requestId, - requestKind: classifyTool(toolName), - detail: summarizeToolRequest(toolName, toolInput), - // ... eventId/createdAt - }); +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 - const decision = await waitForApprovalDecision(requestId); - emitRuntimeEvent({ - type: "approval.resolved", - provider: "claudeCode", - sessionId, - requestId, - decision, - // ... eventId/createdAt - }); +Implementation note: - return decision === "accept" || decision === "acceptForSession" - ? { behavior: "allow", updatedInput: toolInput } - : { behavior: "deny", message: "User declined tool execution." }; -}; -``` +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.2.f Hooks and checkpoint signals +### 2.4 Resume cursor strategy -If Claude hooks provide cleaner turn boundaries, convert them into canonical runtime events (`turn.completed` and optionally `checkpoint.captured`) so `CheckpointReactor` remains unchanged: +Define Claude-owned opaque resume state, e.g.: ```ts -hooks: { - Stop: [ - { - matcher: {}, - hooks: [ - async () => { - emitRuntimeEvent({ - type: "checkpoint.captured", - provider: "claudeCode", - sessionId, - threadId, - turnId: activeTurnId, - turnCount: nextTurnCount(), - // ... eventId/createdAt - }); - }, - ], - }, - ], +interface ClaudeResumeCursor { + readonly version: 1; + readonly threadId?: string; + readonly sessionAt?: string; } ``` -#### 2.2.g Runtime control method wiring - -Adapter should expose SDK controls through existing service methods and runtime payload updates: - -1. `interruptTurn()` -> `result.interrupt()` -2. Model override on send turn -> `result.setModel(model)` before enqueuing prompt -3. Runtime mode changes -> `result.setPermissionMode(mode)` if we support live mode switching -4. Thinking budget updates -> `result.setMaxThinkingTokens(tokens)` when configured - -### 2.3 Canonical event mapping - -Map Claude SDK events to `ProviderRuntimeEvent`: - -1. `session.started` / `session.exited` -2. `turn.started` / `turn.completed` -3. `message.delta` / `message.completed` -4. `tool.started` / `tool.completed` -5. `approval.requested` / `approval.resolved` -6. `runtime.error` +Rules: -No provider-native event methods should leak beyond the adapter boundary. +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. -Reference mapping table: +### 2.5 Interrupt and stop semantics -| Claude SDK message/callback | Canonical runtime event(s) | Notes | -|---|---|---| -| assistant partial text | `message.delta` | Emit deltas only (do not resend full content each chunk). | -| assistant final message | `message.completed` | Must include stable `itemId` for dedupe/finalize behavior. | -| result (success) | `turn.completed` (`status: "completed"`) | This is the main turn boundary for checkpointing. | -| result (interrupted/cancelled) | `turn.completed` (`status: "interrupted"`/`"cancelled"`) | Preserve state semantics used by ingestion + UI. | -| result (error) | `runtime.error` and `turn.completed` (`status: "failed"`) | Include concise error message. | -| tool start | `tool.started` | Set `toolKind` using shared classifier (`command`, `file-change`, `other`). | -| tool result | `tool.completed` | Include detail summary used by activity feed. | -| canUseTool prompt | `approval.requested` | Source of request id shown in UI. | -| approval decision returned | `approval.resolved` | Must correlate using same request id. | -| session initialization | `session.started`, optional `thread.started` | Ensure `threadId` is emitted early and consistently. | -| process exit/close | `session.exited` | Required for session status projection cleanup. | +Map orchestration stop/interrupt expectations onto SDK controls: -### 2.4 Session/thread/resume identifiers - -Define explicit adapter semantics: - -1. `sessionId`: adapter-owned stable session id. -2. `threadId`: Claude conversation/session identifier returned as `ProviderThreadId`. -3. `resumeCursor`: provider-specific cursor (for example message id) needed for precise recovery/rollback. - -### 2.5 Rollback/read strategy - -Implement `readThread()` and `rollbackThread()` in a way compatible with `CheckpointReactor`. - -Preferred: - -1. Track per-turn resume cursors and reconstruct session state on rollback. -2. Keep adapter `sessionId` stable across internal rehydration/restart. - -Fallback (explicitly documented if chosen): - -1. Filesystem revert succeeds even when conversation rewind is partial. -2. Reactor behavior is updated to surface a clear warning activity instead of silent drift. - -### 2.6 Adapter unit tests - -Add tests mirroring Codex adapter coverage: - -1. Provider validation (`provider === "claudeCode"`). -2. Event mapping to canonical runtime events. -3. Approval request lifecycle. -4. Resume/recovery behavior. -5. Rollback behavior. +1. `interruptTurn()` -> active query interrupt. +2. `stopSession()` -> close session resources and prevent future sends. +3. `rollbackThread()` -> see Phase 4. --- -## Phase 3: Register adapter in runtime composition +## Phase 3: Provider service and composition -### 3.1 Adapter registry wiring +### 3.1 Register Claude adapter -Update: +Update provider registry layer to include Claude: -1. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts` +1. add `claudeCode` -> `ClaudeCodeAdapter` +2. ensure `ProviderService.listProviderStatuses()` reports Claude availability -Register both Codex and Claude adapters. +### 3.2 Persist provider binding -### 3.2 Server layer composition +Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. -Update: +Required validation: -1. `apps/server/src/serverLayers.ts` +1. Claude bindings survive restart. +2. resume cursor remains opaque and round-trips untouched. +3. stopAll + restart can recover Claude sessions when possible. -Provide `ClaudeCodeAdapterLive` alongside Codex in `makeServerProviderLayer()`. +### 3.3 Provider start routing -### 3.3 Composition tests +Update `ProviderCommandReactor` / orchestration flow: -Update integration tests to ensure: - -1. Registry exposes Claude provider. -2. Provider service can route Claude sessions. +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: Orchestration command/reactor updates - -### 4.1 Decider propagation - -Update `apps/server/src/orchestration/decider.ts`: +## Phase 4: Checkpoint and revert strategy -1. Carry optional `provider` from `thread.turn.start` command into `thread.turn-start-requested` event payload. +Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. -### 4.2 ProviderCommandReactor provider selection +Pick one explicit strategy: -Update `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`: +### Option A: provider-native rewind -1. Prefer provider from turn-start event payload when starting a new session. -2. Fallback to existing thread session provider when payload omitted. -3. Fallback to default provider only when neither is present. +If SDK/runtime supports safe rewind: -Switch behavior policy (explicit in implementation): +1. implement in Claude adapter +2. keep `CheckpointReactor` unchanged -1. If active session provider differs from requested provider, stop and recreate session before sending turn. -2. Keep current provider when request omits provider. +### Option B: session restart + state truncation shim -### 4.3 Reactor/invariant tests +If no native rewind exists: -Update/add tests in: +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. -1. `apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts` -2. `apps/server/src/orchestration/*.test.ts` as needed +Whichever option is chosen: -Validate first-turn provider choice, provider switching semantics, and backward compatibility when provider is omitted. +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 provider UX enablement +## Phase 5: Web integration -### 5.1 Enable Claude option +### 5.1 Provider picker and model picker -Update `apps/web/src/session-logic.ts`: +Update web state/UI: -1. Mark Claude provider option as available. +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 -### 5.2 Provider selection in composer +Likely touch points: -Update `apps/web/src/components/ChatView.tsx`: +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` -1. Add provider picker near model/runtime controls. -2. Include selected provider in `thread.turn.start` command payload. -3. Keep selected provider sticky per thread in client state (or derive from active session provider when present). +### 5.2 Settings for Claude executable/options -### 5.3 Provider-aware model picker +Add app settings if needed for: -Update model picker usage so model options reflect selected provider. +1. Claude binary path +2. default permission mode +3. default max thinking tokens -### 5.4 Web tests +Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`. -Update/add tests for: - -1. Provider picker interactions. -2. Turn-start command includes provider. -3. Model list switches by provider. - ---- +### 5.3 Session rendering -## Phase 6: Checkpoint and revert compatibility +No new WS channel should be needed. Claude should appear through existing: -### 6.1 Validate checkpoint reactor expectations +1. thread messages +2. activities/worklog +3. approvals +4. session state +5. checkpoints/diffs -Exercise `apps/server/src/orchestration/Layers/CheckpointReactor.ts` flows with Claude: - -1. Turn baseline capture. -2. Turn completion checkpoint capture. -3. Revert + provider rollback coordination. +--- -### 6.2 Policy for partial rollback support +## Phase 6: Testing strategy -If Claude cannot support full conversation rewind at parity with Codex: +### 6.1 Contract tests -1. Document behavior clearly. -2. Emit explicit thread activity for degraded rollback behavior. -3. Ensure filesystem state remains correct and predictable. +Cover: -### 6.3 Checkpoint tests +1. provider-aware model schemas +2. provider field on turn-start command +3. provider-specific start options schema -Add/update tests in checkpoint + provider service integration suites for Claude revert behavior. +### 6.2 Adapter layer tests ---- +Add `ClaudeCodeAdapter.test.ts` covering: -## Phase 7: End-to-end hardening and observability +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 -### 7.1 Integration tests +Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests. -Add integration coverage for: +### 6.3 Provider service integration tests -1. `thread.turn.start` with `provider: "claudeCode"` from fresh thread. -2. Session recovery after restart (provider session alias/recovery path). -3. Approval request/respond flow. -4. Interrupt behavior. -5. Revert flow with rollback. +Extend provider integration coverage so Claude is exercised through `ProviderService`: -### 7.2 WS behavior regression check +1. start Claude session +2. send turn +3. receive canonical runtime events +4. restart/recover using persisted binding -Ensure no contract regressions: +### 6.4 Orchestration integration tests -1. Client still consumes only `orchestration.domainEvent`. -2. No new provider-specific WS channels introduced. +Add/extend integration tests around: -### 7.3 Logging +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 -Confirm both native and canonical provider logs remain useful with multi-adapter setup: - -1. `provider-native.ndjson` -2. `provider-canonical.ndjson` +These should validate real orchestration flows, not just adapter behavior. --- -## File checklist +## Phase 7: Rollout order -Likely files to touch: +Recommended implementation order: -1. `packages/contracts/src/model.ts` -2. `packages/contracts/src/orchestration.ts` -3. `packages/contracts/src/provider.ts` -4. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` (new) -5. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` (new) -6. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts` -7. `apps/server/src/serverLayers.ts` -8. `apps/server/src/orchestration/decider.ts` -9. `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` -10. `apps/web/src/session-logic.ts` -11. `apps/web/src/components/ChatView.tsx` -12. Related tests under `packages/contracts/src`, `apps/server/src/provider/Layers`, `apps/server/src/orchestration/Layers`, `apps/server/integration`, and `apps/web/src`. +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 --- -## Delivery order - -1. Contracts for provider selection + models. -2. Claude adapter + unit tests. -3. Registry/layer wiring. -4. Reactor updates for provider-aware session start. -5. Web provider picker + provider-aware models. -6. Checkpoint/revert compatibility. -7. End-to-end integration tests and stabilization. +## Non-goals -This order keeps risk isolated and maintains a working orchestrated path at each stage. +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/.test-favicon.mjs b/.test-favicon.mjs deleted file mode 100644 index 5c9d741806..0000000000 --- a/.test-favicon.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import path from "path"; -import fs from "fs"; - -const FAVICON_CANDIDATES = [ - "favicon.svg", "favicon.ico", "favicon.png", - "public/favicon.svg", "public/favicon.ico", "public/favicon.png", - "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico", - "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", -]; - -const ICON_SOURCE_FILES = [ - "index.html", "public/index.html", - "app/routes/__root.tsx", "src/routes/__root.tsx", - "app/root.tsx", "src/root.tsx", "src/index.html", -]; - -const LINK_ICON_HTML_RE = /]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; -const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; - -function test(projectCwd) { - console.log("--- Testing:", projectCwd, "---"); - - for (const c of FAVICON_CANDIDATES) { - const full = path.join(projectCwd, c); - if (fs.existsSync(full) && fs.statSync(full).isFile()) { - console.log(" Phase 1 FOUND:", c); - return; - } - } - console.log(" Phase 1: no match"); - - for (const sf of ICON_SOURCE_FILES) { - const full = path.join(projectCwd, sf); - if (!fs.existsSync(full)) continue; - const content = fs.readFileSync(full, "utf8"); - const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1]; - if (href) { - const clean = href.replace(/^\//, ""); - const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; - for (const p of tryPaths) { - if (fs.existsSync(p)) { - console.log(" Phase 2 FOUND:", sf, "->", href, "->", p); - return; - } - } - console.log(" Phase 2 href found but file missing:", href); - } - } - console.log(" Phase 2: no match"); -} - -function getTestPaths() { - const cliPaths = process.argv.slice(2).map((p) => p.trim()).filter(Boolean); - if (cliPaths.length > 0) return cliPaths; - - const envPaths = process.env.TEST_PATHS?.split(path.delimiter).map((p) => p.trim()).filter(Boolean); - if (envPaths?.length) return envPaths; - - return [process.cwd()]; -} - -for (const p of getTestPaths()) { - test(path.resolve(p)); -} diff --git a/AGENTS.md b/AGENTS.md index 37a3c14991..93e9f62907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ -# CLAUDE.md +# AGENTS.md ## Task Completion Requirements - Both `bun lint` and `bun typecheck` must pass before considering tasks completed. +- NEVER run `bun test`. Always use `bun run test` (runs Vitest). ## Project Snapshot diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 175a3f5528..0000000000 --- a/SPEC.md +++ /dev/null @@ -1,582 +0,0 @@ -# Orchestration Interface Spec (Required State) - -Status: Required target architecture (not a description of current behavior). - -## 1. Identifier Schema (Branded) - -All identifiers are branded and never plain `string` in schema definitions. - -```ts -const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); -const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); -const CommandId = Schema.String.pipe(Schema.brand("CommandId")); -const EventId = Schema.String.pipe(Schema.brand("EventId")); -const MessageId = Schema.String.pipe(Schema.brand("MessageId")); -const TurnId = Schema.String.pipe(Schema.brand("TurnId")); - -const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); -const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); -const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); -const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); -const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); - -const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); -``` - -Rules: - -- `ThreadId` is app/orchestration thread identity. -- `ProviderThreadId` is provider runtime thread identity. -- They are never assigned to each other. -- `ProviderSessionId` is internal server/provider routing identity; clients do not send it. -- use e.g. `ThreadId.make(randomUUID())` to create new entity ids at the source (e.g. client create thread id and send to server, provider adapter creates the provider thread id from the codex app server etc) - -## 2. Client Commands (Domain Commands) - -All commands are sent through one RPC: `orchestration.dispatchCommand`. - -Output for every command: `DispatchResultSchema = { sequence: number }`. - -### 2.1 Client-Dispatchable Commands - -1. `project.create` - -- Input: - - `commandId: CommandId` - - `projectId: ProjectId` - - `title: string` - - `workspaceRoot: string` - - `defaultModel?: string` - - `createdAt: IsoDateTime` -- Output: `DispatchResult` - -2. `project.meta.update` - -- Input: - - `commandId: CommandId` - - `projectId: ProjectId` - - optional `{ title, workspaceRoot, defaultModel, scripts }` -- Output: `DispatchResult` - -3. `project.delete` - -- Input: `commandId`, `projectId` -- Output: `DispatchResult` - -4. `thread.create` - -- Input: - - `commandId: CommandId` - - `threadId: ThreadId` - - `projectId: ProjectId` - - `title: string` - - `model: string` - - `branch: string | null` - - `worktreePath: string | null` - - `createdAt: IsoDateTime` -- Output: `DispatchResult` - -5. `thread.delete` - -- Input: `commandId`, `threadId` -- Output: `DispatchResult` - -6. `thread.meta.update` - -- Input: `commandId`, `threadId`, optional `{ title, model, branch, worktreePath }` -- Output: `DispatchResult` - -7. `thread.turn.start` - -- Input: - - `commandId` - - `threadId` - - `message`: - - `messageId: MessageId` - - `role: "user"` - - `text: string` - - `attachments: ChatAttachment[]` (schema-shared) - - `model?: string` - - `effort?: string` - - `createdAt` -- Output: `DispatchResult` - -8. `thread.turn.interrupt` - -- Input: `commandId`, `threadId`, optional `turnId: TurnId`, `createdAt` -- Output: `DispatchResult` - -9. `thread.approval.respond` - -- Input: - - `commandId` - - `threadId` - - `requestId: ApprovalRequestId` - - `decision: "accept" | "acceptForSession" | "decline" | "cancel"` - - `createdAt` -- Output: `DispatchResult` - -10. `thread.checkpoint.revert` - -- Input: `commandId`, `threadId`, `turnCount: number`, `createdAt` -- Output: `DispatchResult` - -11. `thread.session.stop` - -- Input: `commandId`, `threadId`, `createdAt` -- Output: `DispatchResult` - -### 2.2 Internal-Only Commands (Not Client Dispatchable) - -1. `thread.session.set` - -- Server-owned projection update for session lifecycle. - -2. `thread.message.assistant.delta` - -- Server-owned incremental assistant content append. - -3. `thread.message.assistant.complete` - -- Server-owned assistant message completion marker. - -4. `thread.turn.diff.complete` - -- Server-owned checkpoint diff summary write. - -5. `thread.activity.append` - -- Server-owned activity feed append. - -## 3. Client RPC (Required) - -These are the required client-facing RPC methods. - -1. `orchestration.getSnapshot` - -- Input: `{}` -- Output: `OrchestrationReadModel` - -2. `orchestration.dispatchCommand` - -- Input: `ClientOrchestrationCommand` (`Schema.Union` of all client-dispatchable commands) -- Output: `DispatchResult` - -3. `orchestration.getTurnDiff` - -- Input: - - `threadId: ThreadId` - - `fromTurnCount: number` - - `toTurnCount: number` -- Output: - - `threadId: ThreadId` - - `fromTurnCount: number` - - `toTurnCount: number` - - `diff: string` - -4. `orchestration.replayEvents` (optional operational/debug API) - -- Input: `{ fromSequenceExclusive: number }` -- Output: `OrchestrationEvent[]` - -### 3.1 RPC To Remove - -Remove from client boundary: - -- `providers.startSession` -- `providers.sendTurn` -- `providers.interruptTurn` -- `providers.respondToRequest` -- `providers.stopSession` -- `providers.listCheckpoints` -- `providers.getCheckpointDiff` -- `providers.revertToCheckpoint` - -Rationale: - -- Session/turn/checkpoint control belongs to orchestrator command path. -- Snapshot already includes checkpoint summaries (`checkpoints`); only full textual diff needs explicit query RPC. - -## 4. Server Orchestrator Command <-> Event Schemas - -Each command deterministically yields one or more domain events that are persisted to the event store. - -1. `project.create` -> `project.created` -2. `project.meta.update` -> `project.meta-updated` -3. `project.delete` -> `project.deleted` -4. `thread.create` -> `thread.created` -5. `thread.delete` -> `thread.deleted` -6. `thread.meta.update` -> `thread.meta-updated` -7. `thread.turn.start` -> - -- `thread.message-sent` (user message append) -- `thread.turn-start-requested` -- then (async side effect success) `thread.session-set` + provider-driven message/turn events - -8. `thread.turn.interrupt` -> `thread.turn-interrupt-requested` -9. `thread.approval.respond` -> `thread.approval-response-requested` -10. `thread.checkpoint.revert` -> `thread.checkpoint-revert-requested` then `thread.reverted` -11. `thread.session.stop` -> `thread.session-stop-requested` then `thread.session-set` - -Internal command/event path: - -- `thread.session.set` -> `thread.session-set` -- `thread.message.assistant.delta` -> `thread.message-sent` (assistant streaming) -- `thread.message.assistant.complete` -> `thread.message-sent` (streaming false) -- `thread.turn.diff.complete` -> `thread.turn-diff-completed` -- `thread.activity.append` -> `thread.activity-appended` - -## 5. Orchestration Events <-> Generic Provider Events <-> Raw Provider Events - -Required mapping pipeline: - -`Raw Provider Event (adapter-specific)` --> `GenericProviderRuntimeEvent` (provider-agnostic schema) --> `Internal Orchestration Command` --> `Orchestration Domain Event` - -### 5.1 Mapping Table for Codex Adapter - -1. Raw `thread/started` - -- Generic: `thread.started` -- Internal command: `thread.session.set` (attach `ProviderThreadId`, status `ready|running`) -- Domain event: `thread.session-set` - -2. Raw `turn/started` - -- Generic: `turn.started` -- Internal command: `thread.session.set` (status `running`, active turn) -- Domain event: `thread.session-set` - -3. Raw `item/agentMessage/delta` - -- Generic: `message.delta` -- Internal command: `thread.message.assistant.delta` -- Domain event: `thread.message-sent` (assistant, streaming) - -4. Raw `item/completed` (agent message) - -- Generic: `message.completed` -- Internal command: `thread.message.assistant.complete` -- Domain event: `thread.message-sent` (assistant, completed) - -5. Raw `turn/completed` - -- Generic: `turn.completed` -- Internal commands: - - `thread.session.set` (ready/error) - - `thread.activity.append` (optional summary) -- Domain events: `thread.session-set`, `thread.activity-appended` - -6. Raw approval request - -- Generic: `approval.requested` -- Internal command: `thread.activity.append` (typed `requestId`, `requestKind`) -- Domain event: `thread.activity-appended` - -7. Raw approval decision ack - -- Generic: `approval.resolved` -- Internal command: `thread.activity.append` -- Domain event: `thread.activity-appended` - -8. Raw tool start/complete - -- Generic: `tool.started` / `tool.completed` -- Internal command: `thread.activity.append` -- Domain event: `thread.activity-appended` - -9. Checkpoint capture (server-produced) - -- Generic: `checkpoint.captured` -- Internal command: `thread.turn.diff.complete` -- Domain event: `thread.turn-diff-completed` - -10. Raw/runtime error - -- Generic: `runtime.error` -- Internal commands: - - `thread.session.set` (status `error`) - - `thread.activity.append` (`tone=error`) -- Domain events: `thread.session-set`, `thread.activity-appended` - -## 6. Schema Section (Boundary + Reuse) - -### 6.1 Shared Schemas (contracts package) - -Expose to both web and server: - -- `OrchestrationReadModelSchema` -- `ClientOrchestrationCommandSchema` -- `OrchestrationEventSchema` -- `OrchestrationRpcSchemas` (`getSnapshot`, `dispatchCommand`, `getTurnDiff`, optional replay) -- `GenericProviderRuntimeEventSchema` (used for diagnostics/ingestion tests) -- Shared message/activity/checkpoint summary schemas used in snapshot - -### 6.2 Server-Internal Schemas - -Not exposed to web: - -- `InternalOrchestrationCommandSchema` -- Adapter raw-event schemas and provider-specific payload schemas -- Provider session directory records and persistence-only row schemas -- Git service cache/query schemas (non-orchestration read model) - -### 6.3 Reuse Requirements - -- Snapshot checkpoint summary schema is source of truth for checkpoint list UI. -- `getTurnDiff` response reuses `ThreadId`/turn count brands and textual `diff` only. -- Activity schemas are reused for approval/tool/runtime annotations; no separate UI-only structure. -- Session schema must include both `threadId: ThreadId` and optional `providerThreadId: ProviderThreadId` (explicitly distinct). - -## 7. Persistence Model (Required) - -This section defines required persisted tables, the canonical persisted event envelope, and required projected/read tables. - -### 7.1 Write-Side Persisted Tables - -1. `orchestration_events` (append-only event store) - -- `sequence: number` (global monotonic sequence, primary key) -- `eventId: EventId` (unique) -- `aggregateKind: "project" | "thread"` -- `streamId: ProjectId | ThreadId` (aggregate stream id) -- `streamVersion: number` (per-aggregate monotonic version) -- `eventType: OrchestrationEventType` -- `occurredAt: IsoDateTime` -- `commandId: CommandId | null` -- `causationEventId: EventId | null` -- `correlationId: CommandId | null` -- `actorKind: "client" | "server" | "provider"` -- `payload: OrchestrationEventPayload` (type-validated by `eventType`) -- `metadata: OrchestrationEventMetadata` - -2. `orchestration_command_receipts` (idempotency + ack replay) - -- `commandId: CommandId` (primary key) -- `aggregateKind: "project" | "thread"` -- `aggregateId: ProjectId | ThreadId` -- `acceptedAt: IsoDateTime` -- `resultSequence: number` -- `status: "accepted" | "rejected"` -- `error: string | null` - -3. `checkpoint_diff_blobs` (large plaintext diffs; separate from summaries) - -- `threadId: ThreadId` -- `fromTurnCount: number` -- `toTurnCount: number` -- `diff: string` -- `createdAt: IsoDateTime` -- unique key: `(threadId, fromTurnCount, toTurnCount)` - -4. `provider_session_runtime` (server-internal adapter resume state) - -- `providerSessionId: ProviderSessionId` (primary key) -- `threadId: ThreadId` -- `providerName: string` -- `adapterKey: string` -- `providerThreadId: ProviderThreadId | null` -- `status: "starting" | "running" | "stopped" | "error"` -- `lastSeenAt: IsoDateTime` -- `resumeCursor: JsonValue | null` (adapter-specific opaque state) -- `runtimePayload: JsonValue | null` (adapter-specific opaque state) - -### 7.2 Canonical Persisted Event Schema - -`OrchestrationPersistedEventSchema` (full envelope): - -```ts -type OrchestrationPersistedEvent = { - sequence: number; - eventId: EventId; - aggregateKind: "project" | "thread"; - streamId: ProjectId | ThreadId; - streamVersion: number; - eventType: OrchestrationEventType; - occurredAt: IsoDateTime; - commandId: CommandId | null; - causationEventId: EventId | null; - correlationId: CommandId | null; - actorKind: "client" | "server" | "provider"; - payload: OrchestrationEventPayload; - metadata: { - providerSessionId?: ProviderSessionId; - providerThreadId?: ProviderThreadId; - providerTurnId?: ProviderTurnId; - providerItemId?: ProviderItemId; - adapterKey?: string; - requestId?: ApprovalRequestId; - ingestedAt?: IsoDateTime; - }; -}; -``` - -Rules: - -- `payload` must be schema-discriminated by `eventType`. -- provider identifiers only appear in `metadata` and provider-specific payload sub-shapes; they do not replace `ThreadId`. -- `streamVersion` is concurrency guard for aggregate writes. - -### 7.3 Required Projected Tables (Read Models) - -1. `projection_projects` - -- `projectId: ProjectId` (primary key) -- `title: string` -- `workspaceRoot: string` -- `defaultModel: string | null` -- `scripts: ProjectScript[]` -- `createdAt: IsoDateTime` -- `updatedAt: IsoDateTime` -- `deletedAt: IsoDateTime | null` - -2. `projection_threads` - -- `threadId: ThreadId` (primary key) -- `projectId: ProjectId` -- `title: string` -- `model: string` -- `branch: string | null` -- `worktreePath: string | null` -- `latestTurnId: TurnId | null` -- `createdAt: IsoDateTime` -- `updatedAt: IsoDateTime` -- `deletedAt: IsoDateTime | null` - -3. `projection_thread_messages` - -- `messageId: MessageId` (primary key) -- `threadId: ThreadId` -- `turnId: TurnId | null` -- `role: "user" | "assistant" | "system"` -- `text: string` -- `isStreaming: boolean` -- `createdAt: IsoDateTime` -- `updatedAt: IsoDateTime` - -4. `projection_thread_activities` - -- `activityId: EventId` (primary key; derived from source event) -- `threadId: ThreadId` -- `turnId: TurnId | null` -- `tone: "info" | "tool" | "approval" | "error"` -- `kind: string` -- `summary: string` -- `payload: JsonValue` -- `createdAt: IsoDateTime` - -5. `projection_thread_sessions` - -- `threadId: ThreadId` (primary key) -- `status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"` -- `providerName: string | null` -- `providerSessionId: ProviderSessionId | null` -- `providerThreadId: ProviderThreadId | null` -- `activeTurnId: TurnId | null` -- `lastError: string | null` -- `updatedAt: IsoDateTime` - -6. `projection_thread_turns` - -- `turnId: TurnId` (primary key) -- `threadId: ThreadId` -- `turnCount: number` -- `status: "running" | "completed" | "interrupted" | "error"` -- `userMessageId: MessageId | null` -- `assistantMessageId: MessageId | null` -- `startedAt: IsoDateTime` -- `completedAt: IsoDateTime | null` - -7. `projection_checkpoints` - -- `threadId: ThreadId` -- `turnId: TurnId` -- `checkpointTurnCount: number` -- `checkpointRef: CheckpointRef` -- `status: "ready" | "missing" | "error"` -- `files: JsonArray` (typed as file diff summary schema) -- `assistantMessageId: MessageId | null` -- `completedAt: IsoDateTime` -- unique key: `(threadId, checkpointTurnCount)` - -8. `projection_pending_approvals` - -- `requestId: ApprovalRequestId` (primary key) -- `threadId: ThreadId` -- `turnId: TurnId | null` -- `status: "pending" | "resolved"` -- `decision: "accept" | "acceptForSession" | "decline" | "cancel" | null` -- `createdAt: IsoDateTime` -- `resolvedAt: IsoDateTime | null` - -9. `projection_state` - -- `projector: string` (primary key; e.g. `threads`, `messages`, `sessions`) -- `lastAppliedSequence: number` -- `updatedAt: IsoDateTime` - -Projection consistency rules: - -- Every projector applies read-model row updates and `projection_state.lastAppliedSequence` in the same database transaction. -- Optional debug field on projection rows: `lastEventSequence: number` (not required for correctness). - -### 7.4 Snapshot and RPC Requirements - -1. `orchestration.getSnapshot` is fully served from projection tables and returns `snapshotSequence: number`. -2. Snapshot must include `projects[]` from `projection_projects`. -3. Thread snapshot must include `checkpoints[]` from `projection_checkpoints`: - -- `turnId: TurnId` -- `completedAt: IsoDateTime` -- `status` -- `files[]` (`path`, `kind`, `additions`, `deletions`) -- `checkpointRef: CheckpointRef` -- `assistantMessageId?: MessageId` -- `checkpointTurnCount: number` - -4. Client does not require `listCheckpoints` RPC: - -- checkpoint list comes from snapshot projections -- full diff text comes from `orchestration.getTurnDiff` backed by `checkpoint_diff_blobs` - -5. Provider session identity is not a client routing key: - -- client acts on `ThreadId` -- server resolves provider session internally via `projection_thread_sessions` - -6. `snapshotSequence` is derived from `projection_state`: - -- if snapshot depends on multiple projectors, use the minimum `lastAppliedSequence` across those projectors. - -7. Event subscription handoff contract: - -- client performs `getSnapshot` and reads `snapshotSequence` -- client subscribes/replays with `fromSequenceExclusive = snapshotSequence` -- server guarantees no gap between snapshot visibility and subsequent event stream from that sequence. - -### 7.5 External Derived State (Non-Orchestration) - -1. Current git/worktree state is not projected from orchestration events. -2. If persisted, it belongs in a separate git cache/read model (for example `git_state_cache`) owned by a git service. -3. Orchestration may embed git metadata only when captured as a domain fact (for example checkpoint metadata at turn completion). -4. Any RPC for current git status should be outside orchestration RPC (for example `git.getThreadState`). - -### 7.6 Existing Repository Placement - -1. `ProjectsRepository` - -- Fits as projection/query access on top of `projection_projects` plus orchestration command dispatch for writes. -- Must not bypass command->event append path for mutations. - -2. `CheckpointsRepository` - -- Fits as projection/query access on top of `projection_checkpoints` plus `checkpoint_diff_blobs` (or git-service on-demand diff implementation). -- Represents the same concept as prior `turn_diff_summary`; canonical naming is `checkpoint`. -- Must not be an independent source of truth outside orchestration events. - -3. `ProviderSessionsRepository` - -- Fits as server-internal runtime persistence on top of `provider_session_runtime`, with domain-visible state projected into `projection_thread_sessions`. -- Not part of client RPC/domain aggregate boundary. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 45eb883c6e..a634e8882c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +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 { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; @@ -185,229 +187,264 @@ export interface OrchestrationIntegrationHarness { readonly dispose: Effect.Effect; } -export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { - const sleep = (ms: number) => Effect.sleep(ms); - const adapterHarness = yield* makeTestProviderAdapterHarness; - - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); - const workspaceDir = path.join(rootDir, "workspace"); - const stateDir = path.join(rootDir, "state"); - const dbPath = path.join(stateDir, "state.sqlite"); - fs.mkdirSync(workspaceDir, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - initializeGitWorkspace(workspaceDir); +interface MakeOrchestrationIntegrationHarnessOptions { + readonly provider?: "codex" | "claudeCode"; + readonly realCodex?: boolean; +} - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; +export const makeOrchestrationIntegrationHarness = ( + options?: MakeOrchestrationIntegrationHarnessOptions, +) => + Effect.gen(function* () { + const sleep = (ms: number) => Effect.sleep(ms); + const provider = options?.provider ?? "codex"; + const useRealCodex = options?.realCodex === true; + const adapterHarness = useRealCodex + ? null + : yield* makeTestProviderAdapterHarness({ + provider, + }); + const fakeRegistry = adapterHarness + ? Layer.succeed(ProviderAdapterRegistry, { + getByProvider: (resolvedProvider) => + resolvedProvider === adapterHarness.provider + ? Effect.succeed(adapterHarness.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed([adapterHarness.provider]), + } as typeof ProviderAdapterRegistry.Service) + : null; + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); + const workspaceDir = path.join(rootDir, "workspace"); + const stateDir = path.join(rootDir, "state"); + const dbPath = path.join(stateDir, "state.sqlite"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + initializeGitWorkspace(workspaceDir); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), - ); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + const realCodexRegistry = Layer.effect( + ProviderAdapterRegistry, + Effect.gen(function* () { + const codexAdapter = yield* CodexAdapter; + return { + getByProvider: (resolvedProvider) => + resolvedProvider === "codex" + ? Effect.succeed(codexAdapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed(["codex"] as const), + } as typeof ProviderAdapterRegistry.Service; + }), + ).pipe( + Layer.provide(makeCodexAdapterLive()), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerSessionDirectoryLayer), + ); + const providerLayer = useRealCodex + ? makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(realCodexRegistry), + ) + : makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(fakeRegistry!), + ); - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - ProjectionCheckpointRepositoryLive, - ProjectionPendingApprovalRepositoryLive, - CheckpointStoreLive, - providerLayer, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => - Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); - const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: null }), - } as unknown as TextGenerationShape); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), - Layer.provideMerge(textGenerationLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - const layer = orchestrationReactorLayer.pipe( - Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), - Layer.provideMerge(NodeServices.layer), - ); + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + ProjectionCheckpointRepositoryLive, + ProjectionPendingApprovalRepositoryLive, + CheckpointStoreLive, + providerLayer, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const gitCoreLayer = Layer.succeed(GitCore, { + renameBranch: (input: Parameters[0]) => + Effect.succeed({ branch: input.newBranch }), + } as unknown as GitCoreShape); + const textGenerationLayer = Layer.succeed(TextGeneration, { + generateBranchName: () => Effect.succeed({ branch: null }), + } as unknown as TextGenerationShape); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + const layer = orchestrationReactorLayer.pipe( + Layer.provide(persistenceLayer), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(NodeServices.layer), + ); - const runtime = ManagedRuntime.make(layer); - const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => - runtime.runPromise(Effect.service(OrchestrationEngineService)), - ).pipe(Effect.orDie); - const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => - runtime.runPromise(Effect.service(OrchestrationReactor)), - ).pipe(Effect.orDie); - const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => - runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), - ).pipe(Effect.orDie); - const providerService = yield* tryRuntimePromise("load ProviderService service", () => - runtime.runPromise(Effect.service(ProviderService)), - ).pipe(Effect.orDie); - const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => - runtime.runPromise(Effect.service(CheckpointStore)), - ).pipe(Effect.orDie); - const checkpointRepository = yield* tryRuntimePromise( - "load ProjectionCheckpointRepository service", - () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), - ).pipe(Effect.orDie); - const pendingApprovalRepository = yield* tryRuntimePromise( - "load ProjectionPendingApprovalRepository service", - () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), - ).pipe(Effect.orDie); + const runtime = ManagedRuntime.make(layer); + const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => + runtime.runPromise(Effect.service(OrchestrationEngineService)), + ).pipe(Effect.orDie); + const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => + runtime.runPromise(Effect.service(OrchestrationReactor)), + ).pipe(Effect.orDie); + const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => + runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), + ).pipe(Effect.orDie); + const providerService = yield* tryRuntimePromise("load ProviderService service", () => + runtime.runPromise(Effect.service(ProviderService)), + ).pipe(Effect.orDie); + const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => + runtime.runPromise(Effect.service(CheckpointStore)), + ).pipe(Effect.orDie); + const checkpointRepository = yield* tryRuntimePromise( + "load ProjectionCheckpointRepository service", + () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), + ).pipe(Effect.orDie); + const pendingApprovalRepository = yield* tryRuntimePromise( + "load ProjectionPendingApprovalRepository service", + () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), + ).pipe(Effect.orDie); - const scope = yield* Scope.make("sequential"); - yield* tryRuntimePromise("start OrchestrationReactor", () => - runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), - ).pipe(Effect.orDie); - yield* sleep(10); + const scope = yield* Scope.make("sequential"); + yield* tryRuntimePromise("start OrchestrationReactor", () => + runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), + ).pipe(Effect.orDie); + yield* sleep(10); - const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( - threadId, - predicate, - timeoutMs, - ) => - waitFor( - snapshotQuery - .getSnapshot() - .pipe( - Effect.map( - (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, - ), - ), - (thread): thread is OrchestrationThread => thread !== null && predicate(thread), - `projected thread '${threadId}'`, + const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( + threadId, + predicate, timeoutMs, - ) as Effect.Effect; + ) => + waitFor( + snapshotQuery + .getSnapshot() + .pipe( + Effect.map( + (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, + ), + ), + (thread): thread is OrchestrationThread => thread !== null && predicate(thread), + `projected thread '${threadId}'`, + timeoutMs, + ) as Effect.Effect; - const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( - predicate, - timeoutMs, - ) => - waitFor( - Stream.runCollect(engine.readEvents(0)).pipe( - Effect.map((chunk): ReadonlyArray => Array.from(chunk)), - ), - (events) => events.some(predicate), - "domain event", + const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( + predicate, timeoutMs, - ); + ) => + waitFor( + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk): ReadonlyArray => Array.from(chunk)), + ), + (events) => events.some(predicate), + "domain event", + timeoutMs, + ); - const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( - requestId, - predicate, - timeoutMs, - ) => - waitFor( - pendingApprovalRepository - .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) - .pipe( - Effect.map((row) => - Option.match(row, { - onNone: () => null, - onSome: (value) => ({ - status: value.status, - decision: value.decision, - resolvedAt: value.resolvedAt, + const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( + requestId, + predicate, + timeoutMs, + ) => + waitFor( + pendingApprovalRepository + .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) + .pipe( + Effect.map((row) => + Option.match(row, { + onNone: () => null, + onSome: (value) => ({ + status: value.status, + decision: value.decision, + resolvedAt: value.resolvedAt, + }), }), - }), + ), ), - ), - ( - row, - ): row is { - readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; - readonly resolvedAt: string | null; - } => row !== null && predicate(row), - `pending approval '${requestId}'`, - timeoutMs, - ) as Effect.Effect< - { - readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; - readonly resolvedAt: string | null; - }, - never - >; + ( + row, + ): row is { + readonly status: "pending" | "resolved"; + readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly resolvedAt: string | null; + } => row !== null && predicate(row), + `pending approval '${requestId}'`, + timeoutMs, + ) as Effect.Effect< + { + readonly status: "pending" | "resolved"; + readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly resolvedAt: string | null; + }, + never + >; - let disposed = false; - const dispose = Effect.gen(function* () { - if (disposed) { - return; - } - disposed = true; + let disposed = false; + const dispose = Effect.gen(function* () { + if (disposed) { + return; + } + disposed = true; - const shutdown = Effect.gen(function* () { - const stopAllExit = yield* Effect.exit( - Effect.promise(() => runtime.runPromise(providerService.stopAll())), - ); - const closeScopeExit = yield* Effect.exit( - Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))), - ); - const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); + 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(stopAllExit) - ? stopAllExit.cause - : 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); - } + if (failureCause) { + return yield* Effect.failCause(failureCause); + } + }); + + yield* shutdown.pipe( + Effect.ensuring( + Effect.sync(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }), + ), + ); }); - yield* shutdown.pipe( - Effect.ensuring( - Effect.sync(() => { - fs.rmSync(rootDir, { recursive: true, force: true }); - }), - ), - ); + return { + rootDir, + workspaceDir, + dbPath, + adapterHarness: adapterHarness as TestProviderAdapterHarness, + engine, + snapshotQuery, + providerService, + checkpointStore, + checkpointRepository, + pendingApprovalRepository, + waitForThread, + waitForDomainEvent, + waitForPendingApproval, + dispose, + } satisfies OrchestrationIntegrationHarness; }); - - return { - rootDir, - workspaceDir, - dbPath, - adapterHarness, - engine, - snapshotQuery, - providerService, - checkpointStore, - checkpointRepository, - pendingApprovalRepository, - waitForThread, - waitForDomainEvent, - waitForPendingApproval, - dispose, - } satisfies OrchestrationIntegrationHarness; -}); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 4b18819f37..25ce8773bd 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -5,11 +5,11 @@ import { EventId, ProviderApprovalDecision, ProviderRuntimeEvent, + RuntimeSessionId, ProviderSession, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ProviderTurnStartResult, + ThreadId, + TurnId, } from "@t3tools/contracts"; import { Effect, Queue, Stream } from "effect"; @@ -25,13 +25,29 @@ import type { } from "../src/provider/Services/ProviderAdapter.ts"; export interface TestTurnResponse { - readonly events: ReadonlyArray; + readonly events: ReadonlyArray; readonly mutateWorkspace?: (input: { readonly cwd: string; readonly turnCount: number; }) => Effect.Effect; } +export type FixtureProviderRuntimeEvent = { + readonly type: string; + readonly eventId: EventId; + readonly provider: "codex" | "claudeCode" | "cursor"; + readonly createdAt: string; + readonly threadId: string; + readonly turnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly payload?: unknown | undefined; + readonly [key: string]: unknown; +}; + +// Temporary alias while fixtures migrate to the new name. +export type LegacyProviderRuntimeEvent = FixtureProviderRuntimeEvent; + interface SessionState { readonly session: ProviderSession; snapshot: ProviderThreadSnapshot; @@ -40,82 +56,217 @@ interface SessionState { readonly rollbackCalls: Array; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupted" | "cancelled" { + if ( + value === "completed" || + value === "failed" || + value === "interrupted" || + value === "cancelled" + ) { + return value; + } + return "completed"; +} + +function mapRequestType(requestKind: unknown): + | "command_execution_approval" + | "file_change_approval" + | "unknown" { + if (requestKind === "command") { + return "command_execution_approval"; + } + if (requestKind === "file-change") { + return "file_change_approval"; + } + return "unknown"; +} + +function mapItemType(toolKind: unknown): "command_execution" | "file_change" | "unknown" { + if (toolKind === "command") { + return "command_execution"; + } + if (toolKind === "file-change") { + return "file_change"; + } + return "unknown"; +} + +function normalizeFixtureEvent(rawEvent: Record): ProviderRuntimeEvent { + const type = typeof rawEvent.type === "string" ? rawEvent.type : ""; + switch (type) { + case "turn.started": + return { + ...rawEvent, + type: "turn.started", + payload: isRecord(rawEvent.payload) ? rawEvent.payload : {}, + } as ProviderRuntimeEvent; + case "turn.completed": + return { + ...rawEvent, + type: "turn.completed", + payload: isRecord(rawEvent.payload) + ? rawEvent.payload + : { + state: normalizeTurnState(rawEvent.status), + }, + } as ProviderRuntimeEvent; + case "message.delta": + return { + ...rawEvent, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: typeof rawEvent.delta === "string" ? rawEvent.delta : "", + }, + } as ProviderRuntimeEvent; + case "message.completed": + return { + ...rawEvent, + type: "item.completed", + payload: { + itemType: "assistant_message", + ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + }, + } as ProviderRuntimeEvent; + case "tool.started": + return { + ...rawEvent, + type: "item.started", + payload: { + itemType: mapItemType(rawEvent.toolKind), + ...(typeof rawEvent.title === "string" ? { title: rawEvent.title } : {}), + ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + }, + } as ProviderRuntimeEvent; + case "tool.completed": + return { + ...rawEvent, + type: "item.completed", + payload: { + itemType: mapItemType(rawEvent.toolKind), + status: "completed", + ...(typeof rawEvent.title === "string" ? { title: rawEvent.title } : {}), + ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + }, + } as ProviderRuntimeEvent; + case "approval.requested": + return { + ...rawEvent, + type: "request.opened", + payload: { + requestType: mapRequestType(rawEvent.requestKind), + ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + }, + } as ProviderRuntimeEvent; + case "approval.resolved": + return { + ...rawEvent, + type: "request.resolved", + payload: { + requestType: mapRequestType(rawEvent.requestKind), + ...(typeof rawEvent.decision === "string" ? { decision: rawEvent.decision } : {}), + }, + } as ProviderRuntimeEvent; + default: + return rawEvent as ProviderRuntimeEvent; + } +} + export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( - sessionId: string, + threadId: ThreadId, response: TestTurnResponse, ) => Effect.Effect; readonly queueTurnResponseForNextSession: ( response: TestTurnResponse, ) => Effect.Effect; - readonly getRollbackCalls: (sessionId: string) => ReadonlyArray; - readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{ - readonly sessionId: ProviderSessionId; + readonly getStartCount: () => number; + readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray; + readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray; + readonly listActiveSessionIds: () => ReadonlyArray; + readonly getApprovalResponses: (threadId: ThreadId) => ReadonlyArray<{ + readonly threadId: ThreadId; readonly requestId: ApprovalRequestId; readonly decision: ProviderApprovalDecision; }>; } -const PROVIDER = "codex" as const; +interface MakeTestProviderAdapterHarnessOptions { + readonly provider?: "codex" | "claudeCode"; +} function nowIso(): string { return new Date().toISOString(); } -function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError { +function sessionNotFound( + provider: "codex" | "claudeCode", + threadId: ThreadId, +): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - sessionId, + provider, + threadId: String(threadId), }); } -function missingSessionEffect(sessionId: string): Effect.Effect { - return Effect.fail(sessionNotFound(sessionId)); +function missingSessionEffect( + provider: "codex" | "claudeCode", + threadId: ThreadId, +): Effect.Effect { + return Effect.fail(sessionNotFound(provider, threadId)); } -export const makeTestProviderAdapterHarness = Effect.gen(function* () { - const runtimeEvents = yield* Queue.unbounded(); - let sessionCount = 0; - const sessions = new Map(); - const queuedResponsesForNextSession: TestTurnResponse[] = []; - const approvalResponsesBySession = new Map< - string, - Array<{ - readonly sessionId: ProviderSessionId; - readonly requestId: ApprovalRequestId; - readonly decision: ProviderApprovalDecision; - }> - >(); +export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => + Effect.gen(function* () { + const provider = options?.provider ?? "codex"; + const runtimeEvents = yield* Queue.unbounded(); + let sessionCount = 0; + const sessions = new Map(); + const queuedResponsesForNextSession: TestTurnResponse[] = []; + const interruptCallsBySession = new Map>(); + const approvalResponsesBySession = new Map< + ThreadId, + Array<{ + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + readonly decision: ProviderApprovalDecision; + }> + >(); const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); const startSession: ProviderAdapterShape["startSession"] = (input) => Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { + if (input.provider !== undefined && input.provider !== provider) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + issue: `Expected provider '${provider}' but received '${input.provider}'.`, }); } sessionCount += 1; - const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); - const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); + const threadId = input.threadId; const createdAt = nowIso(); const session: ProviderSession = { - sessionId, - provider: PROVIDER, + provider, status: "ready", + runtimeMode: input.runtimeMode, threadId, cwd: input.cwd, + resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, createdAt, updatedAt: createdAt, }; - sessions.set(sessionId, { + sessions.set(threadId, { session, snapshot: { threadId, @@ -131,21 +282,21 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const state = sessions.get(input.sessionId); + const state = sessions.get(input.threadId); if (!state) { - return yield* missingSessionEffect(input.sessionId); + return yield* missingSessionEffect(provider, input.threadId); } state.turnCount += 1; const turnCount = state.turnCount; - const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); + const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); const response = state.queuedResponses.shift(); if (!response) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "sendTurn", - issue: `No queued turn response for session ${input.sessionId}.`, + issue: `No queued turn response for thread ${input.threadId}.`, }); } @@ -155,20 +306,27 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const rawEvent: Record = { ...(fixtureEvent as Record), eventId: randomUUID(), - provider: PROVIDER, - sessionId: input.sessionId, + provider, + sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), createdAt: nowIso(), }; - if (Object.hasOwn(rawEvent, "threadId")) { - rawEvent.threadId = state.snapshot.threadId; - } + rawEvent.threadId = state.snapshot.threadId; if (Object.hasOwn(rawEvent, "turnId")) { rawEvent.turnId = turnId; } - const runtimeEvent = rawEvent as ProviderRuntimeEvent; - if (runtimeEvent.type === "message.delta") { - assistantDeltas.push(runtimeEvent.delta); + const runtimeEvent = normalizeFixtureEvent(rawEvent); + const runtimeType = (runtimeEvent as { type: string }).type; + if (runtimeType === "content.delta") { + const payload = runtimeEvent.payload as { delta?: unknown } | undefined; + if (typeof payload?.delta === "string") { + assistantDeltas.push(payload.delta); + } + } else if (runtimeType === "message.delta") { + const legacyDelta = (runtimeEvent as { delta?: unknown }).delta; + if (typeof legacyDelta === "string") { + assistantDeltas.push(legacyDelta); + } } if (runtimeEvent.type === "turn.completed") { deferredTurnCompletedEvents.push(runtimeEvent); @@ -206,12 +364,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { yield* emit({ type: "turn.completed", eventId: EventId.makeUnsafe(randomUUID()), - provider: PROVIDER, - sessionId: input.sessionId, + provider, createdAt: nowIso(), threadId: state.snapshot.threadId, turnId, - status: "completed", + payload: { + state: "completed", + }, }); } else { for (const completedEvent of deferredTurnCompletedEvents) { @@ -226,58 +385,71 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { }); const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( - sessionId, - _turnId, - ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); + threadId, + turnId, + ) => + sessions.has(threadId) + ? Effect.sync(() => { + const existing = interruptCallsBySession.get(threadId) ?? []; + existing.push(turnId); + interruptCallsBySession.set(threadId, existing); + }) + : missingSessionEffect(provider, threadId); const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => - sessions.has(sessionId) + sessions.has(threadId) ? Effect.sync(() => { - const existing = approvalResponsesBySession.get(sessionId) ?? []; + const existing = approvalResponsesBySession.get(threadId) ?? []; existing.push({ - sessionId, + threadId, requestId, decision, }); - approvalResponsesBySession.set(sessionId, existing); + approvalResponsesBySession.set(threadId, existing); }) - : missingSessionEffect(sessionId); + : missingSessionEffect(provider, threadId); + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( + threadId, + _requestId, + _answers, + ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); - const stopSession: ProviderAdapterShape["stopSession"] = (sessionId) => + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { - sessions.delete(sessionId); + sessions.delete(threadId); }); const listSessions: ProviderAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); - const hasSession: ProviderAdapterShape["hasSession"] = (sessionId) => - Effect.succeed(sessions.has(sessionId)); + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.succeed(sessions.has(threadId)); - const readThread: ProviderAdapterShape["readThread"] = (sessionId) => { - const state = sessions.get(sessionId); + const readThread: ProviderAdapterShape["readThread"] = (threadId) => { + const state = sessions.get(threadId); if (!state) { - return missingSessionEffect(sessionId); + return missingSessionEffect(provider, threadId); } return Effect.succeed(state.snapshot); }; const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( - sessionId, + threadId, numTurns, ) => { - const state = sessions.get(sessionId); + const state = sessions.get(threadId); if (!state) { - return missingSessionEffect(sessionId); + return missingSessionEffect(provider, threadId); } if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { return Effect.fail( new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "rollbackThread", issue: "numTurns must be an integer between 0 and current turn count.", }), @@ -301,11 +473,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { }); const adapter: ProviderAdapterShape = { - provider: PROVIDER, + provider, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, @@ -316,16 +492,16 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { }; const queueTurnResponse = ( - sessionId: string, + threadId: ThreadId, response: TestTurnResponse, ): Effect.Effect => - Effect.sync(() => sessions.get(sessionId)).pipe( + Effect.sync(() => sessions.get(threadId)).pipe( Effect.flatMap((state) => state ? Effect.sync(() => { state.queuedResponses.push(response); }) - : Effect.fail(sessionNotFound(sessionId)), + : Effect.fail(sessionNotFound(provider, threadId)), ), ); @@ -336,22 +512,35 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { queuedResponsesForNextSession.push(response); }); - const getRollbackCalls = (sessionId: string): ReadonlyArray => { - const state = sessions.get(sessionId); + const getRollbackCalls = (threadId: ThreadId): ReadonlyArray => { + const state = sessions.get(threadId); if (!state) { return []; } return [...state.rollbackCalls]; }; + const getStartCount = (): number => sessionCount; + + const getInterruptCalls = (threadId: ThreadId): ReadonlyArray => { + const calls = interruptCallsBySession.get(threadId); + if (!calls) { + return []; + } + return [...calls]; + }; + + const listActiveSessionIds = (): ReadonlyArray => + Array.from(sessions.values(), (state) => state.session.threadId); + const getApprovalResponses = ( - sessionId: string, + threadId: ThreadId, ): ReadonlyArray<{ - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly requestId: ApprovalRequestId; readonly decision: ProviderApprovalDecision; }> => { - const responses = approvalResponsesBySession.get(sessionId); + const responses = approvalResponsesBySession.get(threadId); if (!responses) { return []; } @@ -360,9 +549,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { return { adapter, + provider, queueTurnResponse, queueTurnResponseForNextSession, + getStartCount, getRollbackCalls, + getInterruptCalls, + listActiveSessionIds, getApprovalResponses, } satisfies TestProviderAdapterHarness; }); diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index c846fcb704..f56a587cb7 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -1,22 +1,16 @@ -import { - ApprovalRequestId, - EventId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - type ProviderRuntimeEvent, -} from "@t3tools/contracts"; +import { EventId, RuntimeRequestId } from "@t3tools/contracts"; +import type { LegacyProviderRuntimeEvent } from "../TestProviderAdapter.integration.ts"; const PROVIDER = "codex" as const; -const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session"); -const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); -const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); -const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); +const SESSION_ID = "fixture-session"; +const THREAD_ID = "fixture-thread"; +const TURN_ID = "fixture-turn"; +const REQUEST_ID = RuntimeRequestId.makeUnsafe("req-1"); function baseEvent( eventId: string, createdAt: string, -): Pick { +): Pick { return { eventId: EventId.makeUnsafe(eventId), provider: PROVIDER, @@ -31,29 +25,38 @@ export const codexTurnTextFixture = [ ...baseEvent("evt-1", "2026-02-23T00:00:00.000Z"), threadId: THREAD_ID, turnId: TURN_ID, + payload: {}, }, { - type: "message.delta", + type: "content.delta", ...baseEvent("evt-2", "2026-02-23T00:00:00.100Z"), threadId: THREAD_ID, turnId: TURN_ID, - delta: "I will make a small update.\n", + payload: { + streamKind: "assistant_text", + delta: "I will make a small update.\n", + }, }, { - type: "message.delta", + type: "content.delta", ...baseEvent("evt-3", "2026-02-23T00:00:00.200Z"), threadId: THREAD_ID, turnId: TURN_ID, - delta: "Done.\n", + payload: { + streamKind: "assistant_text", + delta: "Done.\n", + }, }, { type: "turn.completed", ...baseEvent("evt-4", "2026-02-23T00:00:00.300Z"), threadId: THREAD_ID, turnId: TURN_ID, - status: "completed", + payload: { + state: "completed", + }, }, -] satisfies ReadonlyArray; +] satisfies ReadonlyArray; export const codexTurnToolFixture = [ { @@ -61,40 +64,51 @@ export const codexTurnToolFixture = [ ...baseEvent("evt-11", "2026-02-23T00:01:00.000Z"), threadId: THREAD_ID, turnId: TURN_ID, + payload: {}, }, { - type: "tool.started", + type: "item.started", ...baseEvent("evt-12", "2026-02-23T00:01:00.100Z"), threadId: THREAD_ID, turnId: TURN_ID, - toolKind: "command", - title: "Command run", - detail: "echo integration", + payload: { + itemType: "command_execution", + title: "Command run", + detail: "echo integration", + }, }, { - type: "tool.completed", + type: "item.completed", ...baseEvent("evt-13", "2026-02-23T00:01:00.200Z"), threadId: THREAD_ID, turnId: TURN_ID, - toolKind: "command", - title: "Command run", - detail: "echo integration", + payload: { + itemType: "command_execution", + status: "completed", + title: "Command run", + detail: "echo integration", + }, }, { - type: "message.delta", + type: "content.delta", ...baseEvent("evt-14", "2026-02-23T00:01:00.300Z"), threadId: THREAD_ID, turnId: TURN_ID, - delta: "Applied the requested edit.\n", + payload: { + streamKind: "assistant_text", + delta: "Applied the requested edit.\n", + }, }, { type: "turn.completed", ...baseEvent("evt-15", "2026-02-23T00:01:00.400Z"), threadId: THREAD_ID, turnId: TURN_ID, - status: "completed", + payload: { + state: "completed", + }, }, -] satisfies ReadonlyArray; +] satisfies ReadonlyArray; export const codexTurnApprovalFixture = [ { @@ -102,37 +116,47 @@ export const codexTurnApprovalFixture = [ ...baseEvent("evt-21", "2026-02-23T00:02:00.000Z"), threadId: THREAD_ID, turnId: TURN_ID, + payload: {}, }, { - type: "approval.requested", + type: "request.opened", ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), threadId: THREAD_ID, turnId: TURN_ID, requestId: REQUEST_ID, - requestKind: "command", - detail: "Please approve command", + payload: { + requestType: "command_execution_approval", + detail: "Please approve command", + }, }, { - type: "approval.resolved", + type: "request.resolved", ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), threadId: THREAD_ID, turnId: TURN_ID, requestId: REQUEST_ID, - requestKind: "command", - decision: "accept", + payload: { + requestType: "command_execution_approval", + decision: "accept", + }, }, { - type: "message.delta", + type: "content.delta", ...baseEvent("evt-24", "2026-02-23T00:02:00.300Z"), threadId: THREAD_ID, turnId: TURN_ID, - delta: "Approval received and command executed.\n", + payload: { + streamKind: "assistant_text", + delta: "Approval received and command executed.\n", + }, }, { type: "turn.completed", ...baseEvent("evt-25", "2026-02-23T00:02:00.400Z"), threadId: THREAD_ID, turnId: TURN_ID, - status: "completed", + payload: { + state: "completed", + }, }, -] satisfies ReadonlyArray; +] satisfies ReadonlyArray; diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index e12b8d2bd1..606087f0ac 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -4,12 +4,10 @@ import path from "node:path"; import { ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -27,19 +25,14 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); -const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); -const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); -const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); +const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -76,18 +69,30 @@ function waitForSync( }); } -function runtimeBase(eventId: string, createdAt: string) { +function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { return { eventId: asEventId(eventId), - provider: "codex" as const, - sessionId: FIXTURE_SESSION_ID, + provider, createdAt, }; } -function withHarness(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect) { +function withHarness( + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, + provider: IntegrationProvider = "codex", +) { return Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness, + makeOrchestrationIntegrationHarness({ provider }), + use, + (harness) => harness.dispose, + ); +} + +function withRealCodexHarness( + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, +) { + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), use, (harness) => harness.dispose, ); @@ -114,6 +119,8 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: harness.workspaceDir, createdAt, @@ -125,6 +132,7 @@ const startTurn = (input: { readonly commandId: string; readonly messageId: string; readonly text: string; + readonly provider?: IntegrationProvider; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -136,8 +144,9 @@ const startTurn = (input: { text: input.text, attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + ...(input.provider !== undefined ? { provider: input.provider } : {}), + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: nowIso(), }); @@ -151,20 +160,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + { type: "turn.started", ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Single turn response.\n", }, { type: "turn.completed", ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -211,6 +220,95 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + ), ); +it.live.skipIf(!process.env.CODEX_BINARY_PATH)( + "keeps the same Codex provider thread across runtime mode switches", + () => + withRealCodexHarness((harness) => + Effect.gen(function* () { + const createdAt = nowIso(); + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"), + projectId: PROJECT_ID, + title: "Integration Project", + workspaceRoot: harness.workspaceDir, + defaultModel: "gpt-5.3-codex", + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Integration Thread", + model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: harness.workspaceDir, + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"), + threadId: THREAD_ID, + message: { + messageId: asMessageId("msg-real-codex-1"), + role: "user", + text: "Reply with exactly ALPHA.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: nowIso(), + }); + + const firstThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.session.providerName === "codex" && + entry.messages.some( + (message) => message.role === "assistant" && message.streaming === false, + ), + 180_000, + ); + assert.equal(firstThread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"), + threadId: THREAD_ID, + message: { + messageId: asMessageId("msg-real-codex-2"), + role: "user", + text: "Reply with exactly BETA.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: nowIso(), + }); + + const secondThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.session.providerName === "codex" && + entry.session.runtimeMode === "approval-required" && + entry.messages.some( + (message) => message.role === "assistant" && message.text.includes("BETA"), + ), + 180_000, + ); + assert.equal(secondThread.session?.threadId, "thread-1"); + }), + ), +); + it.live("runs multi-turn file edits and persists checkpoint diffs", () => withHarness((harness) => Effect.gen(function* () { @@ -221,13 +319,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "turn.started", ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -236,7 +334,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "tool.completed", ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -245,14 +343,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "message.delta", ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v2.\n", }, { type: "turn.completed", ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -270,35 +368,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => text: "Make first edit", }); - const firstTurnThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null, + (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", ); - const sessionId = firstTurnThread.session?.providerSessionId; - assert.equal(sessionId !== null, true); - if (!sessionId) { - return; - } - yield* harness.adapterHarness.queueTurnResponse(sessionId, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v3.\n", }, { type: "turn.completed", ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -385,13 +478,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons { type: "turn.started", ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, requestKind: "command", @@ -400,7 +493,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons { type: "turn.completed", ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -444,9 +537,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons assert.equal(resolvedRow.status, "resolved"); assert.equal(resolvedRow.decision, "accept"); - const providerSessionId = thread.session?.providerSessionId ?? "test-session-1"; const approvalResponses = yield* waitForSync( - () => harness.adapterHarness.getApprovalResponses(providerSessionId), + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), (responses) => responses.length === 1, "provider approval response", ); @@ -467,30 +559,37 @@ it.live("records failed turn runtime state and checkpoint status as error", () = { type: "turn.started", ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { - type: "message.delta", + type: "content.delta", ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - delta: "Partial output before failure.\n", + payload: { + streamKind: "assistant_text", + delta: "Partial output before failure.\n", + }, }, { type: "runtime.error", ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - message: "Sandbox command failed.", + payload: { + message: "Sandbox command failed.", + }, }, { type: "turn.completed", ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, - status: "failed", - errorMessage: "Sandbox command failed.", + payload: { + state: "failed", + errorMessage: "Sandbox command failed.", + }, }, ], }); @@ -539,13 +638,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "turn.started", ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -554,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "tool.completed", ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -563,14 +662,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "message.delta", ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v2.\n", }, { type: "turn.completed", ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -587,28 +686,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git text: "First edit", }); - const firstTurnThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1, + (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, ); - const sessionId = firstTurnThread.session?.providerSessionId; - assert.equal(sessionId !== null, true); - if (!sessionId) { - return; - } - yield* harness.adapterHarness.queueTurnResponse(sessionId, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -617,7 +711,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "tool.completed", ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -626,14 +720,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "message.delta", ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v3.\n", }, { type: "turn.completed", ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -702,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(sessionId), [1]); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ threadId: THREAD_ID, @@ -748,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/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 2e9ae06b61..5c1362f231 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,4 +1,4 @@ -import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; @@ -42,7 +42,7 @@ interface IntegrationFixture { const makeIntegrationFixture = Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; - const harness = yield* makeTestProviderAdapterHarness; + const harness = yield* makeTestProviderAdapterHarness(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => @@ -93,18 +93,18 @@ const collectEventsDuring = ( const runTurn = (input: { readonly provider: ProviderServiceShape; readonly harness: TestProviderAdapterHarness; - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly userText: string; readonly response: TestTurnResponse; }) => Effect.gen(function* () { - yield* input.harness.queueTurnResponse(input.sessionId, input.response); + yield* input.harness.queueTurnResponse(input.threadId, input.response); return yield* collectEventsDuring( input.provider.streamEvents, input.response.events.length, input.provider.sendTurn({ - sessionId: input.sessionId, + threadId: input.threadId, input: input.userText, attachments: [], }), @@ -120,8 +120,10 @@ it.effect("replays typed runtime fixture events", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-typed"), { + threadId: ThreadId.makeUnsafe("thread-integration-typed"), provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -129,7 +131,7 @@ it.effect("replays typed runtime fixture events", () => const observedEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "hello", response: { events: codexTurnTextFixture }, }); @@ -153,8 +155,10 @@ it.effect("replays file-changing fixture turn events", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-tools"), { + threadId: ThreadId.makeUnsafe("thread-integration-tools"), provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -162,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () => const observedEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "make a small change", response: { events: codexTurnToolFixture, @@ -190,8 +194,10 @@ it.effect("runs multi-turn tool/approval flow", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-multi"), { + threadId: ThreadId.makeUnsafe("thread-integration-multi"), provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -199,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () => const firstTurnEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 1", response: { events: codexTurnToolFixture, @@ -215,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () => const secondTurnEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 2 approval", response: { events: codexTurnApprovalFixture, @@ -242,8 +248,10 @@ it.effect("rolls back provider conversation state only", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-rollback"), { + threadId: ThreadId.makeUnsafe("thread-integration-rollback"), provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -251,7 +259,7 @@ it.effect("rolls back provider conversation state only", () => yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 1", response: { events: codexTurnToolFixture, @@ -263,7 +271,7 @@ it.effect("rolls back provider conversation state only", () => yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 2 approval", response: { events: codexTurnApprovalFixture, @@ -273,11 +281,11 @@ it.effect("rolls back provider conversation state only", () => }); yield* provider.rollbackConversation({ - sessionId: session.sessionId, + threadId: session.threadId, numTurns: 1, }); - const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); + const rollbackCalls = fixture.harness.getRollbackCalls(session.threadId); assert.deepEqual(rollbackCalls, [1]); const readme = yield* readFileString(join(fixture.cwd, "README.md")); diff --git a/apps/server/package.json b/apps/server/package.json index eb54ec15b4..2d24a958bd 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/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 786253d5a4..2f79ea9d5a 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -1,5 +1,6 @@ import { CheckpointRef, + DEFAULT_PROVIDER_INTERACTION_MODE, ProjectId, ThreadId, TurnId, @@ -43,6 +44,8 @@ function makeSnapshot(input: { projectId: input.projectId, title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: input.worktreePath, latestTurn: { @@ -58,6 +61,7 @@ function makeSnapshot(input: { deletedAt: null, messages: [], activities: [], + proposedPlans: [], checkpoints: [ { turnId: TurnId.makeUnsafe("turn-1"), diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 7ec669d4c6..6032a1e3cf 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -1,14 +1,20 @@ import { describe, expect, it, vi } from "vitest"; -import { ProviderSessionId } from "@t3tools/contracts"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { + buildCodexInitializeParams, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, } from "./codexAppServerManager"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); function createSendTurnHarness() { const manager = new CodexAppServerManager(); @@ -18,6 +24,7 @@ function createSendTurnHarness() { provider: "codex", status: "ready", threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, @@ -54,6 +61,7 @@ function createThreadControlHarness() { provider: "codex", status: "ready", threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, @@ -76,6 +84,46 @@ function createThreadControlHarness() { return { manager, context, requireSession, sendRequest, updateSession }; } +function createPendingUserInputHarness() { + const manager = new CodexAppServerManager(); + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "ready", + threadId: "thread_1", + resumeCursor: { threadId: "thread_1" }, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + pendingUserInputs: new Map([ + [ + ApprovalRequestId.makeUnsafe("req-user-input-1"), + { + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + jsonRpcId: 42, + threadId: asThreadId("thread_1"), + }, + ], + ]), + }; + + const requireSession = vi + .spyOn( + manager as unknown as { requireSession: (sessionId: string) => unknown }, + "requireSession", + ) + .mockReturnValue(context); + const writeMessage = vi + .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") + .mockImplementation(() => {}); + const emitEvent = vi + .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .mockImplementation(() => {}); + + return { manager, context, requireSession, writeMessage, emitEvent }; +} + describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); @@ -147,6 +195,19 @@ describe("isRecoverableThreadResumeError", () => { }); describe("startSession", () => { + it("enables Codex experimental api capabilities during initialize", () => { + expect(buildCodexInitializeParams()).toEqual({ + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + }); + }); + it("emits session/startFailed when resolving cwd throws before process launch", async () => { const manager = new CodexAppServerManager(); const events: Array<{ method: string; kind: string; message?: string }> = []; @@ -164,7 +225,9 @@ describe("startSession", () => { try { await expect( manager.startSession({ + threadId: asThreadId("thread-1"), provider: "codex", + runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); expect(events).toHaveLength(1); @@ -186,7 +249,7 @@ describe("sendTurn", () => { createSendTurnHarness(); const result = await manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), input: "Inspect this image", attachments: [ { @@ -196,16 +259,15 @@ describe("sendTurn", () => { ], model: "gpt-5.3", effort: "high", + serviceTier: "fast", }); expect(result).toEqual({ threadId: "thread_1", turnId: "turn_1", - resumeCursor: { - threadId: "thread_1", - }, + resumeCursor: { threadId: "thread_1" }, }); - expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(requireSession).toHaveBeenCalledWith("thread_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ @@ -221,13 +283,12 @@ describe("sendTurn", () => { ], model: "gpt-5.3-codex", effort: "high", + serviceTier: "fast", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", activeTurnId: "turn_1", - resumeCursor: { - threadId: "thread_1", - }, + resumeCursor: { threadId: "thread_1" }, }); }); @@ -235,7 +296,7 @@ describe("sendTurn", () => { const { manager, context, sendRequest } = createSendTurnHarness(); await manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), attachments: [ { type: "image", @@ -255,12 +316,42 @@ describe("sendTurn", () => { }); }); + it("passes Codex plan mode as a collaboration preset on turn/start", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Plan the work", + interactionMode: "plan", + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Plan the work", + text_elements: [], + }, + ], + model: "gpt-5.3-codex", + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }, + }); + }); + it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); await expect( manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), }), ).rejects.toThrow("Turn input must include text or attachments."); }); @@ -281,9 +372,9 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.readThread(asSessionId("sess_1")); + const result = await manager.readThread(asThreadId("thread_1")); - expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(requireSession).toHaveBeenCalledWith("thread_1"); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", includeTurns: true, @@ -311,7 +402,7 @@ describe("thread checkpoint control", () => { ], }); - const result = await manager.readThread(asSessionId("sess_1")); + const result = await manager.readThread(asThreadId("thread_1")); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", @@ -337,7 +428,7 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.rollbackThread(asSessionId("sess_1"), 2); + const result = await manager.rollbackThread(asThreadId("thread_1"), 2); expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { threadId: "thread_1", @@ -353,3 +444,129 @@ describe("thread checkpoint control", () => { }); }); }); + +describe("respondToUserInput", () => { + it("serializes canonical answers to Codex native answer objects", async () => { + const { manager, context, requireSession, writeMessage, emitEvent } = + createPendingUserInputHarness(); + + await manager.respondToUserInput( + asThreadId("thread_1"), + ApprovalRequestId.makeUnsafe("req-user-input-1"), + { + scope: "All request methods", + compat: "Keep current envelope", + }, + ); + + expect(requireSession).toHaveBeenCalledWith("thread_1"); + expect(writeMessage).toHaveBeenCalledWith(context, { + id: 42, + result: { + answers: { + scope: { answers: ["All request methods"] }, + compat: { answers: ["Keep current envelope"] }, + }, + }, + }); + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + method: "item/tool/requestUserInput/answered", + payload: { + requestId: "req-user-input-1", + answers: { + scope: { answers: ["All request methods"] }, + compat: { answers: ["Keep current envelope"] }, + }, + }, + }), + ); + }); +}); + +describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { + it( + "keeps prior thread history when resuming with a changed runtime mode", + async () => { + const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); + writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8"); + + const manager = new CodexAppServerManager(); + + try { + const firstSession = await manager.startSession({ + threadId: asThreadId("thread-live"), + provider: "codex", + cwd: workspaceDir, + runtimeMode: "full-access", + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH + ? { binaryPath: process.env.CODEX_BINARY_PATH } + : {}), + ...(process.env.CODEX_HOME_PATH + ? { homePath: process.env.CODEX_HOME_PATH } + : {}), + }, + }, + }); + + const firstTurn = await manager.sendTurn({ + threadId: firstSession.threadId, + input: `Reply with exactly the word ALPHA ${randomUUID()}`, + }); + + expect(firstTurn.threadId).toBe(firstSession.threadId); + + await vi.waitFor(async () => { + const snapshot = await manager.readThread(firstSession.threadId); + expect(snapshot.turns.length).toBeGreaterThan(0); + }, { timeout: 120_000, interval: 1_000 }); + + const firstSnapshot = await manager.readThread(firstSession.threadId); + const originalThreadId = firstSnapshot.threadId; + const originalTurnCount = firstSnapshot.turns.length; + + manager.stopSession(firstSession.threadId); + + const resumedSession = await manager.startSession({ + threadId: firstSession.threadId, + provider: "codex", + cwd: workspaceDir, + runtimeMode: "approval-required", + resumeCursor: firstSession.resumeCursor, + providerOptions: { + codex: { + ...(process.env.CODEX_BINARY_PATH + ? { binaryPath: process.env.CODEX_BINARY_PATH } + : {}), + ...(process.env.CODEX_HOME_PATH + ? { homePath: process.env.CODEX_HOME_PATH } + : {}), + }, + }, + }); + + expect(resumedSession.threadId).toBe(originalThreadId); + + const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); + expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); + expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); + + await manager.sendTurn({ + threadId: resumedSession.threadId, + input: `Reply with exactly the word BETA ${randomUUID()}`, + }); + + await vi.waitFor(async () => { + const snapshot = await manager.readThread(resumedSession.threadId); + expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); + }, { timeout: 120_000, interval: 1_000 }); + } finally { + manager.stopAll(); + rmSync(workspaceDir, { recursive: true, force: true }); + } + }, + 180_000, + ); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 7b19c7be5c..b8447237f2 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -8,16 +8,19 @@ import { EventId, ProviderItemId, ProviderRequestKind, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - normalizeModelSlug, + type ProviderUserInputAnswers, + ThreadId, + TurnId, type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, type ProviderSessionStartInput, type ProviderTurnStartResult, + RuntimeMode, + ProviderInteractionMode, } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { Effect, ServiceMap } from "effect"; type PendingRequestKey = string; @@ -33,17 +36,30 @@ interface PendingApprovalRequest { jsonRpcId: string | number; method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; - threadId?: ProviderThreadId; - turnId?: ProviderTurnId; + threadId: ThreadId; + turnId?: TurnId; itemId?: ProviderItemId; } +interface PendingUserInputRequest { + requestId: ApprovalRequestId; + jsonRpcId: string | number; + threadId: ThreadId; + turnId?: TurnId; + itemId?: ProviderItemId; +} + +interface CodexUserInputAnswer { + answers: string[]; +} + interface CodexSessionContext { session: ProviderSession; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; pendingApprovals: Map; + pendingUserInputs: Map; nextRequestId: number; stopping: boolean; } @@ -71,20 +87,33 @@ interface JsonRpcNotification { } export interface CodexAppServerSendTurnInput { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; readonly effort?: string; + readonly serviceTier?: string; + readonly interactionMode?: ProviderInteractionMode; +} + +export interface CodexAppServerStartSessionInput { + readonly threadId: ThreadId; + readonly provider?: "codex"; + readonly cwd?: string; + readonly model?: string; + readonly serviceTier?: string; + readonly resumeCursor?: unknown; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly runtimeMode: RuntimeMode; } export interface CodexThreadTurnSnapshot { - id: ProviderTurnId; + id: TurnId; items: unknown[]; } export interface CodexThreadSnapshot { - threadId: ProviderThreadId; + threadId: string; turns: CodexThreadTurnSnapshot[]; } @@ -103,6 +132,144 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; +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. + +## Mode rules (strict) + +You are in **Plan Mode** until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. + +Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +* Reading or searching files, configs, schemas, types, manifests, and docs +* Static analysis, inspection, and repo exploration +* Dry-run style commands when they do not edit repo-tracked files +* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +* Editing or writing files +* Running formatters or linters that rewrite files +* Applying patches, migrations, or codegen that updates repo-tracked files +* Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 - Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + +Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. + +## PHASE 2 - Intent chat (what they actually want) + +* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. + +## PHASE 3 - Implementation chat (what/how we'll build) + +* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. + +## Asking questions + +Critical rules: + +* Strongly prefer using the \`request_user_input\` tool to ask any questions. +* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. +* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. + +You SHOULD ask many questions, but each question must: + +* materially change the spec/plan, OR +* confirm/lock an assumption, OR +* choose between meaningful tradeoffs. +* not be answerable by non-mutating commands. + +Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. + +## Two kinds of unknowns (treat differently) + +1. **Discoverable facts** (repo/system truth): explore first. + + * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + * If asking, present concrete candidates (paths/service names) + recommend one. + * Never ask questions you can answer from your environment (e.g., "where is this struct"). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + * These are intent or implementation preferences that cannot be derived from exploration. + * Provide 2-4 mutually exclusive options + a recommended default. + * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. + +## Finalization rule + +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +When you present the official plan, wrap it in a \`\` block so the client can render it specially: + +1) The opening tag must be on its own line. +2) Start the plan content on the next line (no text on the same line as the tag). +3) The closing tag must be on its own line. +4) Use Markdown inside the block. +5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +plan content + + +plan content should be human and agent digestible. The final plan must be plan-only and include: + +* A clear title +* A brief summary section +* Important changes or additions to public APIs/interfaces/types +* Test cases and scenarios +* Explicit assumptions and defaults chosen where needed + +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. + +Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +`; + +function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { + readonly approvalPolicy: "on-request" | "never"; + readonly sandbox: "workspace-write" | "danger-full-access"; +} { + if (runtimeMode === "approval-required") { + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + } + + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; +} /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` @@ -137,6 +304,83 @@ export function normalizeCodexModelSlug( return normalized; } +export function buildCodexInitializeParams() { + return { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + } as const; +} + +function buildCodexCollaborationMode(input: { + readonly interactionMode?: "default" | "plan"; + readonly model?: string; + readonly effort?: string; +}): + | { + mode: "plan"; + settings: { + model: string; + reasoning_effort: string; + developer_instructions: string; + }; + } + | undefined { + if (input.interactionMode !== "plan") { + return undefined; + } + const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex"; + return { + mode: "plan", + settings: { + model, + reasoning_effort: input.effort ?? "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }; +} + +function toCodexUserInputAnswer(value: unknown): CodexUserInputAnswer { + if (typeof value === "string") { + return { answers: [value] }; + } + + if (Array.isArray(value)) { + const answers = value.filter((entry): entry is string => typeof entry === "string"); + 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"); + if (answers.length > 0) { + return { answers }; + } + } + } + + throw new Error("User input answers must be strings or arrays of strings."); +} + +function toCodexUserInputAnswers( + answers: ProviderUserInputAnswers, +): Record { + return Object.fromEntries( + Object.entries(answers).map(([questionId, value]) => [ + questionId, + toCodexUserInputAnswer(value), + ]), + ); +} + export function classifyCodexStderrLine(rawLine: string): { message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { @@ -173,10 +417,16 @@ export interface CodexAppServerManagerEvents { } export class CodexAppServerManager extends EventEmitter { - private readonly sessions = new Map(); + private readonly sessions = new Map(); - async startSession(input: ProviderSessionStartInput): Promise { - const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); + private runPromise: (effect: Effect.Effect) => Promise; + constructor(services?: ServiceMap.ServiceMap) { + super(); + this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise; + } + + async startSession(input: CodexAppServerStartSessionInput): Promise { + const threadId = input.threadId; const now = new Date().toISOString(); let context: CodexSessionContext | undefined; @@ -184,17 +434,19 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(input.sessionId); - if (!context.session.threadId) { - throw new Error("Session is missing a thread id."); - } + const context = this.requireSession(input.threadId); const turnInput: Array< { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } @@ -343,24 +628,55 @@ export class CodexAppServerManager extends EventEmitter; model?: string; effort?: string; + serviceTier?: string; + collaborationMode?: { + mode: "plan"; + settings: { + model: string; + reasoning_effort: string; + developer_instructions: string; + }; + }; } = { - threadId: context.session.threadId, + threadId: providerThreadId, input: turnInput, }; - const normalizedModel = normalizeCodexModelSlug(input.model); + const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model); if (normalizedModel) { turnStartParams.model = normalizedModel; } if (input.effort) { turnStartParams.effort = input.effort; } + if (input.serviceTier) { + turnStartParams.serviceTier = input.serviceTier; + } + const collaborationMode = buildCodexCollaborationMode({ + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(normalizedModel !== undefined ? { model: normalizedModel } : {}), + ...(input.effort !== undefined ? { effort: input.effort } : {}), + }); + if (collaborationMode) { + if (!turnStartParams.model) { + turnStartParams.model = collaborationMode.settings.model; + } + turnStartParams.collaborationMode = collaborationMode; + } const response = await this.sendRequest(context, "turn/start", turnStartParams); @@ -369,64 +685,78 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(sessionId); + async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { + const context = this.requireSession(threadId); const effectiveTurnId = turnId ?? context.session.activeTurnId; - if (!effectiveTurnId || !context.session.threadId) { + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!effectiveTurnId || !providerThreadId) { return; } await this.sendRequest(context, "turn/interrupt", { - threadId: context.session.threadId, + threadId: providerThreadId, turnId: effectiveTurnId, }); } - async readThread(sessionId: ProviderSessionId): Promise { - const context = this.requireSession(sessionId); - const threadId = context.session.threadId; - if (!threadId) { - throw new Error("Session is missing a thread id."); + async readThread(threadId: ThreadId): Promise { + const context = this.requireSession(threadId); + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!providerThreadId) { + throw new Error("Session is missing a provider resume thread id."); } const response = await this.sendRequest(context, "thread/read", { - threadId, + threadId: providerThreadId, includeTurns: true, }); return this.parseThreadSnapshot("thread/read", response); } - async rollbackThread( - sessionId: ProviderSessionId, - numTurns: number, - ): Promise { - const context = this.requireSession(sessionId); - const threadId = context.session.threadId; - if (!threadId) { - throw new Error("Session is missing a thread id."); + async rollbackThread(threadId: ThreadId, numTurns: number): Promise { + const context = this.requireSession(threadId); + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!providerThreadId) { + throw new Error("Session is missing a provider resume thread id."); } if (!Number.isInteger(numTurns) || numTurns < 1) { throw new Error("numTurns must be an integer >= 1."); } const response = await this.sendRequest(context, "thread/rollback", { - threadId, + threadId: providerThreadId, numTurns, }); this.updateSession(context, { @@ -437,11 +767,11 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(sessionId); + const context = this.requireSession(threadId); const pendingRequest = context.pendingApprovals.get(requestId); if (!pendingRequest) { throw new Error(`Unknown pending approval request: ${requestId}`); @@ -459,10 +789,9 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(threadId); + const pendingRequest = context.pendingUserInputs.get(requestId); + if (!pendingRequest) { + throw new Error(`Unknown pending user input request: ${requestId}`); + } + + context.pendingUserInputs.delete(requestId); + const codexAnswers = toCodexUserInputAnswers(answers); + this.writeMessage(context, { + id: pendingRequest.jsonRpcId, + result: { + answers: codexAnswers, + }, + }); + + this.emitEvent({ + id: EventId.makeUnsafe(randomUUID()), + kind: "notification", + provider: "codex", + threadId: context.session.threadId, + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput/answered", + turnId: pendingRequest.turnId, + itemId: pendingRequest.itemId, + requestId: pendingRequest.requestId, + payload: { + requestId: pendingRequest.requestId, + answers: codexAnswers, + }, + }); + } + + stopSession(threadId: ThreadId): void { + const context = this.sessions.get(threadId); if (!context) { return; } @@ -489,6 +855,7 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; - const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); + const turnId = TurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { id: turnId, @@ -875,7 +1247,7 @@ export class CodexAppServerManager extends EventEmitter( return normalized?.length ? maker(normalized) : undefined; } -function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { - return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); +function normalizeProviderThreadId(value: string | undefined): string | undefined { + return brandIfNonEmpty(value, (normalized) => normalized); +} + +function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { + readonly binaryPath?: string; + readonly homePath?: string; +} { + const options = input.providerOptions?.codex; + if (!options) { + return {}; + } + return { + ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), + ...(options.homePath ? { homePath: options.homePath } : {}), + }; } -function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { - if ( - !input.resumeCursor || - typeof input.resumeCursor !== "object" || - Array.isArray(input.resumeCursor) - ) { - return input.resumeThreadId; +function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { + if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { + return undefined; } - const rawThreadId = (input.resumeCursor as Record).threadId; - return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; + const rawThreadId = (resumeCursor as Record).threadId; + return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; +} + +function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { + return readResumeCursorThreadId(input.resumeCursor); } -function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { - return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); +function toTurnId(value: string | undefined): TurnId | undefined { + return brandIfNonEmpty(value, TurnId.makeUnsafe); } function toProviderItemId(value: string | undefined): ProviderItemId | undefined { diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index e0d794aee6..0a36a4ca1b 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -77,7 +77,7 @@ beforeEach(() => { findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); }); -it.layer(testLayer)("server cli", (it) => { +it.layer(testLayer)("server CLI command", (it) => { it.effect("parses all CLI flags and wires scoped start/stop", () => Effect.gen(function* () { yield* runCli([ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4f36e7ea12..1ccc78d4e8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceM import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; +// Dummy comment. import { DEFAULT_PORT, resolveStaticDir, @@ -23,6 +24,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; +import { ServerLoggerLive } from "./serverLogger"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -119,6 +121,9 @@ const CliEnvConfig = Config.all({ ), }); +const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + const ServerConfigLive = (input: CliInput) => Layer.effect( ServerConfig, @@ -150,25 +155,16 @@ const ServerConfigLive = (input: CliInput) => Option.getOrUndefined(input.stateDir) ?? env.stateDir, ); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const noBrowser = Option.match(input.noBrowser, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), - onNone: () => env.noBrowser ?? mode === "desktop", - }); + const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; - const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), - onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", - }); - const logWebSocketEvents = Option.match(input.logWebSocketEvents, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/dev fallbacks to apply. - onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))), - onNone: () => env.logWebSocketEvents ?? Boolean(devUrl), - }); + const autoBootstrapProjectFromCwd = resolveBooleanFlag( + input.autoBootstrapProjectFromCwd, + env.autoBootstrapProjectFromCwd ?? mode === "web", + ); + const logWebSocketEvents = resolveBooleanFlag( + input.logWebSocketEvents, + env.logWebSocketEvents ?? Boolean(devUrl), + ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; const keybindingsConfigPath = join(stateDir, "keybindings.json"); @@ -177,7 +173,7 @@ const ServerConfigLive = (input: CliInput) => env.host ?? (mode === "desktop" ? "127.0.0.1" : undefined); - return { + const config: ServerConfigShape = { mode, port, cwd: cliConfig.cwd, @@ -191,6 +187,8 @@ const ServerConfigLive = (input: CliInput) => autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; + + return config; }), ); @@ -200,6 +198,7 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(makeServerProviderLayer()), Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -234,14 +233,11 @@ const makeServerProgram = (input: CliInput) => config.host && !isWildcardHost(config.host) ? `http://${formatHostForUrl(config.host)}:${config.port}` : localUrl; + const { authToken, devUrl, ...safeConfig } = config; yield* Effect.logInfo("T3 Code running", { - url: bindUrl, - localUrl, - bindHost: config.host ?? "default", - cwd: config.cwd, - mode: config.mode, - stateDir: config.stateDir, - authEnabled: Boolean(config.authToken), + ...safeConfig, + devUrl: devUrl?.toString(), + authEnabled: Boolean(authToken), }); if (!config.noBrowser) { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 25ca97b804..330659ff7d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -6,12 +6,10 @@ import { execFileSync } from "node:child_process"; import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -40,28 +38,43 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd = cwd) { +type LegacyProviderRuntimeEvent = { + readonly type: string; + readonly eventId: EventId; + readonly provider: "codex" | "claudeCode" | "cursor"; + readonly createdAt: string; + readonly threadId: ThreadId; + readonly turnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly payload?: unknown | undefined; + readonly [key: string]: unknown; +}; + +function createProviderServiceHarness( + cwd: string, + hasSession = true, + sessionCwd = cwd, + providerName: ProviderSession["provider"] = "codex", +) { const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const rollbackConversation = vi.fn( - (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void, + (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; const listSessions = () => hasSession - ? Effect.succeed([ + ? Effect.succeed([ { - sessionId: asSessionId("sess-1"), - provider: "codex", + provider: providerName, status: "ready", - threadId: asProviderThreadId("provider-thread-1"), + runtimeMode: "full-access", + threadId: ThreadId.makeUnsafe("thread-1"), cwd: sessionCwd, createdAt: now, updatedAt: now, @@ -73,15 +86,17 @@ function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd sendTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions, + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation, stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; - const emit = (event: ProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); + const emit = (event: LegacyProviderRuntimeEvent): void => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); }; return { @@ -220,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); @@ -227,6 +243,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -271,6 +288,8 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: options?.threadWorktreePath ?? cwd, createdAt, @@ -320,10 +339,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -336,10 +352,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-1"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), }); await waitForGitRefExists( harness.cwd, @@ -351,11 +367,11 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-1"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-1"), - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + payload: { state: "completed" }, }); await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); @@ -399,10 +415,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: asTurnId("turn-main"), lastError: null, updatedAt: createdAt, @@ -415,10 +428,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-main"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-main"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), }); await waitForGitRefExists( harness.cwd, @@ -431,11 +444,11 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-aux"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"), - turnId: asProviderTurnId("turn-aux"), - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-aux"), + payload: { state: "completed" }, }); await Effect.runPromise(Effect.sleep("40 millis")); @@ -449,11 +462,11 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-main"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-main"), - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), + payload: { state: "completed" }, }); const thread = await waitForThread( @@ -463,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(); @@ -476,10 +550,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -492,11 +563,11 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-missing-baseline"), - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, }); await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); @@ -531,8 +602,8 @@ describe("CheckpointReactor", () => { text: "start turn", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: new Date().toISOString(), }), ); @@ -567,10 +638,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-missing"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: asTurnId("turn-missing-cwd"), lastError: null, updatedAt: createdAt, @@ -584,11 +652,11 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), provider: "codex", - sessionId: asSessionId("sess-missing"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), - turnId: asProviderTurnId("turn-missing-cwd"), - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-cwd"), + payload: { state: "completed" }, }); await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); @@ -604,7 +672,7 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); - it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => { + it("ignores non-v2 checkpoint.captured runtime events", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -617,10 +685,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -633,19 +698,19 @@ describe("CheckpointReactor", () => { type: "checkpoint.captured", eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-3"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-3"), turnCount: 3, status: "completed", }); - const thread = await waitForThread(harness.engine, (entry) => - entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), - ); - expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( - true, + await Effect.runPromise(Effect.sleep("40 millis")); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( + false, ); }); @@ -670,10 +735,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -683,25 +745,24 @@ describe("CheckpointReactor", () => { ); harness.provider.emit({ - type: "checkpoint.captured", + type: "turn.completed", eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-runtime-failure"), - turnCount: 1, - status: "completed", + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-runtime-failure"), + payload: { state: "completed" }, }); harness.provider.emit({ type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-after-runtime-failure"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-after-runtime-failure"), }); await waitForGitRefExists( @@ -726,10 +787,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -785,7 +843,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - sessionId: asSessionId("sess-1"), + threadId: ThreadId.makeUnsafe("thread-1"), numTurns: 1, }); expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); @@ -794,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(); @@ -807,10 +934,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: createdAt, @@ -882,11 +1006,11 @@ describe("CheckpointReactor", () => { expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ - sessionId: asSessionId("sess-1"), + threadId: ThreadId.makeUnsafe("thread-1"), numTurns: 1, }); expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ - sessionId: asSessionId("sess-1"), + threadId: ThreadId.makeUnsafe("thread-1"), numTurns: 1, }); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 71b51ba737..07ffac3253 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -2,8 +2,6 @@ import { CommandId, EventId, MessageId, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, type OrchestrationEvent, @@ -34,11 +32,7 @@ type ReactorInput = }; function toTurnId(value: string | undefined): TurnId | null { - return value === undefined ? null : TurnId.makeUnsafe(value); -} - -function toProviderThreadId(value: string | undefined): ProviderThreadId | null { - return value === undefined ? null : ProviderThreadId.makeUnsafe(value); + return value === undefined ? null : TurnId.makeUnsafe(String(value)); } function sameId(left: string | null | undefined, right: string | null | undefined): boolean { @@ -95,77 +89,58 @@ const make = Effect.gen(function* () { }); const appendCaptureFailureActivity = (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly turnId: TurnId | null; readonly detail: string; readonly createdAt: string; }) => - Effect.gen(function* () { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === input.sessionId, - ); - if (!thread) { - return; - } - yield* orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: serverCommandId("checkpoint-capture-failure"), - threadId: thread.id, - activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), - tone: "error", - kind: "checkpoint.capture.failed", - summary: "Checkpoint capture failed", - payload: { - detail: input.detail, - }, - turnId: input.turnId, - createdAt: input.createdAt, + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("checkpoint-capture-failure"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: { + detail: input.detail, }, + turnId: input.turnId, createdAt: input.createdAt, - }); + }, + createdAt: input.createdAt, }); const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( threadId: ThreadId, ): Effect.fn.Return< - Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> + Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> > { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); - const projectedSessionId = thread?.session?.providerSessionId ?? null; - const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined; const sessions = yield* providerService.listSessions(); const findSessionWithCwd = ( session: (typeof sessions)[number] | undefined, - ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { + ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => { if (!session?.cwd) { return Option.none(); } - return Option.some({ sessionId: session.sessionId, cwd: session.cwd }); + return Option.some({ threadId: session.threadId, cwd: session.cwd }); }; - if (projectedSessionId !== null) { - const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId); + if (thread) { + const projectedSession = sessions.find( + (session) => session.threadId === thread.id, + ); const fromProjected = findSessionWithCwd(projectedSession); if (Option.isSome(fromProjected)) { return fromProjected; } } - if (projectedProviderThreadId) { - const matchedSession = sessions.find( - (session) => session.threadId === projectedProviderThreadId, - ); - const fromProviderThread = findSessionWithCwd(matchedSession); - if (Option.isSome(fromProviderThread)) { - return fromProviderThread; - } - } - return Option.none(); }); @@ -178,23 +153,11 @@ const make = Effect.gen(function* () { } const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === event.sessionId, - ); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) { return; } - const projectedProviderThreadId = thread.session?.providerThreadId ?? null; - const eventProviderThreadId = toProviderThreadId(event.threadId); - if ( - projectedProviderThreadId !== null && - eventProviderThreadId !== null && - !sameId(projectedProviderThreadId, eventProviderThreadId) - ) { - return; - } - // When a primary turn is active, only that turn may produce completion checkpoints. if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { return; @@ -266,7 +229,7 @@ const make = Effect.gen(function* () { ), Effect.tapError((error) => appendCaptureFailureActivity({ - sessionId: event.sessionId, + threadId: thread.id, turnId, detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, createdAt: event.createdAt, @@ -296,7 +259,7 @@ const make = Effect.gen(function* () { turnId, completedAt: now, checkpointRef: targetCheckpointRef, - status: checkpointStatusFromRuntime(event.status), + status: checkpointStatusFromRuntime(event.payload.state), files, assistantMessageId, checkpointTurnCount: nextTurnCount, @@ -314,7 +277,7 @@ const make = Effect.gen(function* () { summary: "Checkpoint captured", payload: { turnCount: nextTurnCount, - ...(event.status !== undefined ? { status: event.status } : {}), + status: event.payload.state, }, turnId, createdAt: now, @@ -333,7 +296,7 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === event.sessionId, + (entry) => entry.id === event.threadId, ); if (!thread) { return; @@ -513,7 +476,7 @@ const make = Effect.gen(function* () { const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { yield* providerService.rollbackConversation({ - sessionId: sessionRuntime.value.sessionId, + threadId: sessionRuntime.value.threadId, numTurns: rolledBackTurns, }); } @@ -581,7 +544,7 @@ const make = Effect.gen(function* () { yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => appendCaptureFailureActivity({ - sessionId: event.sessionId, + threadId: event.threadId, turnId, detail: error.message, createdAt: new Date().toISOString(), @@ -590,89 +553,6 @@ const make = Effect.gen(function* () { ); return; } - - if (event.type === "checkpoint.captured") { - const turnId = toTurnId(event.turnId); - if (!turnId) { - return; - } - - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === event.sessionId, - ); - if (!thread) { - return; - } - - if ( - thread.checkpoints.some( - (checkpoint) => - checkpoint.turnId === turnId || checkpoint.checkpointTurnCount === event.turnCount, - ) - ) { - return; - } - - const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); - if (Option.isNone(sessionRuntime)) { - return; - } - - const fromTurnCount = Math.max(0, event.turnCount - 1); - const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount); - const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, event.turnCount); - - const targetExists = yield* checkpointStore.hasCheckpointRef({ - cwd: sessionRuntime.value.cwd, - checkpointRef: targetCheckpointRef, - }); - if (!targetExists) { - yield* checkpointStore.captureCheckpoint({ - cwd: sessionRuntime.value.cwd, - checkpointRef: targetCheckpointRef, - }); - } - - const files = yield* checkpointStore - .diffCheckpoints({ - cwd: sessionRuntime.value.cwd, - fromCheckpointRef, - toCheckpointRef: targetCheckpointRef, - fallbackFromToHead: false, - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ - path: file.path, - kind: "modified" as const, - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.catch(() => Effect.succeed([])), - ); - - const assistantMessageId = - thread.messages - .toReversed() - .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ?? - MessageId.makeUnsafe(`assistant:${turnId}`); - - yield* orchestrationEngine.dispatch({ - type: "thread.turn.diff.complete", - commandId: serverCommandId("checkpoint-runtime-captured-diff-complete"), - threadId: thread.id, - turnId, - completedAt: event.createdAt, - checkpointRef: targetCheckpointRef, - status: checkpointStatusFromRuntime(event.status), - files, - assistantMessageId, - checkpointTurnCount: event.turnCount, - createdAt: event.createdAt, - }); - } }); const processInput = ( @@ -717,11 +597,7 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(providerService.streamEvents, (event) => { - if ( - event.type !== "turn.started" && - event.type !== "turn.completed" && - event.type !== "checkpoint.captured" - ) { + if (event.type !== "turn.started" && event.type !== "turn.completed") { return Effect.void; } return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index a22b8979f2..181b18d60c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, ProjectId, ThreadId, @@ -80,6 +81,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -96,8 +99,8 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt, }), ); @@ -132,6 +135,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "replay", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -192,6 +197,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "domain-stream", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -235,6 +242,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -342,6 +351,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-fail", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -357,6 +368,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-ok", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -420,6 +433,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "atomic", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -436,8 +451,8 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, - approvalPolicy: "on-request" as const, - sandboxMode: "workspace-write" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required" as const, createdAt, }; @@ -553,6 +568,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "sync-before", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -596,8 +613,8 @@ describe("OrchestrationEngine", () => { text: "hello", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now(), }), ), @@ -631,6 +648,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -646,6 +665,8 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 741aa16f16..6e4e824f39 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4,8 +4,6 @@ import { CorrelationId, EventId, MessageId, - ProviderSessionId, - ProviderThreadId, ProjectId, ThreadId, TurnId, @@ -104,6 +102,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread 1", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -374,6 +373,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-clear-attachments"), title: "Thread Clear Attachments", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -500,6 +500,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-overwrite"), title: "Thread Overwrite", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -650,6 +651,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-rollback"), title: "Thread Rollback", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -780,6 +782,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-revert-files"), title: "Thread Revert Files", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -994,6 +997,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-delete-files"), title: "Thread Delete Files", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -1156,6 +1160,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -1279,6 +1284,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-empty"), title: "Thread Empty", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -1415,6 +1421,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-conflict"), title: "Thread Conflict", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: "2026-02-26T13:00:01.000Z", @@ -1555,6 +1562,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-revert"), title: "Thread Revert", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: "2026-02-26T12:00:01.000Z", @@ -1749,8 +1757,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta payload: { threadId, messageId, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", createdAt: turnStartedAt, }, }); @@ -1772,20 +1779,14 @@ it.effect("restores pending turn-start metadata across projection pipeline resta commandId: CommandId.makeUnsafe("cmd-restart-2"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), - metadata: { - providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), - }, + metadata: {}, payload: { threadId, session: { threadId, status: "running", providerName: "codex", - providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: turnId, lastError: null, updatedAt: sessionSetAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f4c8728f39..24b81d514a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -18,6 +18,10 @@ import { type ProjectionThreadMessage, ProjectionThreadMessageRepository, } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { + type ProjectionThreadProposedPlan, + ProjectionThreadProposedPlanRepository, +} from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -29,6 +33,7 @@ import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/Projec import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; @@ -48,6 +53,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", threads: "projection.threads", threadMessages: "projection.thread-messages", + threadProposedPlans: "projection.thread-proposed-plans", threadActivities: "projection.thread-activities", threadSessions: "projection.thread-sessions", threadTurns: "projection.thread-turns", @@ -189,6 +195,26 @@ function retainProjectionActivitiesAfterRevert( ); } +function retainProjectionProposedPlansAfterRevert( + proposedPlans: ReadonlyArray, + turns: ReadonlyArray, + turnCount: number, +): ReadonlyArray { + const retainedTurnIds = new Set( + turns + .filter( + (turn) => + turn.turnId !== null && + turn.checkpointTurnCount !== null && + turn.checkpointTurnCount <= turnCount, + ) + .flatMap((turn) => (turn.turnId === null ? [] : [turn.turnId])), + ); + return proposedPlans.filter( + (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), + ); +} + function collectThreadAttachmentRelativePaths( threadId: string, messages: ReadonlyArray, @@ -320,6 +346,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const projectionProjectRepository = yield* ProjectionProjectRepository; const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; + const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -396,6 +423,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { projectId: event.payload.projectId, title: event.payload.title, model: event.payload.model, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, latestTurnId: null, @@ -425,6 +454,36 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "thread.runtime-mode-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + runtimeMode: event.payload.runtimeMode, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.interaction-mode-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ @@ -442,6 +501,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } case "thread.message-sent": + case "thread.proposed-plan-upserted": case "thread.activity-appended": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -583,6 +643,57 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); + const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => + Effect.gen(function* () { + switch (event.type) { + case "thread.proposed-plan-upserted": + yield* projectionThreadProposedPlanRepository.upsert({ + planId: event.payload.proposedPlan.id, + threadId: event.payload.threadId, + turnId: event.payload.proposedPlan.turnId, + planMarkdown: event.payload.proposedPlan.planMarkdown, + createdAt: event.payload.proposedPlan.createdAt, + updatedAt: event.payload.proposedPlan.updatedAt, + }); + return; + + case "thread.reverted": { + const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + if (existingRows.length === 0) { + return; + } + + const existingTurns = yield* projectionTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const keptRows = retainProjectionProposedPlansAfterRevert( + existingRows, + existingTurns, + event.payload.turnCount, + ); + if (keptRows.length === existingRows.length) { + return; + } + + yield* projectionThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach(keptRows, projectionThreadProposedPlanRepository.upsert, { + concurrency: 1, + }).pipe(Effect.asVoid); + return; + } + + default: + return; + } + }); + const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = ( event, _attachmentSideEffects, @@ -598,6 +709,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { kind: event.payload.activity.kind, summary: event.payload.activity.summary, payload: event.payload.activity.payload, + ...(event.payload.activity.sequence !== undefined + ? { sequence: event.payload.activity.sequence } + : {}), createdAt: event.payload.activity.createdAt, }); return; @@ -646,10 +760,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, status: event.payload.session.status, providerName: event.payload.session.providerName, - providerSessionId: event.payload.session.providerSessionId, - providerThreadId: event.payload.session.providerThreadId, - approvalPolicy: event.payload.session.approvalPolicy, - sandboxMode: event.payload.session.sandboxMode, + runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, updatedAt: event.payload.session.updatedAt, @@ -991,6 +1102,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, apply: applyThreadMessagesProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, + apply: applyThreadProposedPlansProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, apply: applyThreadActivitiesProjection, @@ -1110,6 +1225,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ba99a79648..e7e9cd4e12 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -3,8 +3,6 @@ import { EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), @@ -143,8 +138,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { provider_name, provider_session_id, provider_thread_id, - approval_policy, - sandbox_mode, + runtime_mode, active_turn_id, last_error, updated_at @@ -155,8 +149,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'codex', 'provider-session-1', 'provider-thread-1', - 'on-request', - 'workspace-write', + 'approval-required', 'turn-1', NULL, '2026-02-24T00:00:07.000Z' @@ -241,6 +234,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", model: "gpt-5-codex", + interactionMode: "default", + runtimeMode: "full-access", branch: null, worktreePath: null, latestTurn: { @@ -265,6 +260,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:05.000Z", }, ], + proposedPlans: [], activities: [ { id: asEventId("activity-1"), @@ -291,10 +287,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asProviderSessionId("provider-session-1"), - providerThreadId: asProviderThreadId("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, updatedAt: "2026-02-24T00:00:07.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index a4f260118c..5fd38a5401 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -2,6 +2,7 @@ import { ChatAttachment, IsoDateTime, MessageId, + NonNegativeInt, OrchestrationCheckpointFile, OrchestrationReadModel, ProjectScript, @@ -9,6 +10,7 @@ import { type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, type OrchestrationThread, @@ -29,6 +31,7 @@ import { ProjectionProject } from "../../persistence/Services/ProjectionProjects import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; @@ -49,10 +52,12 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread; const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), }), ); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; @@ -76,6 +81,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, ORCHESTRATION_PROJECTOR_NAMES.threads, ORCHESTRATION_PROJECTOR_NAMES.threadMessages, + ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, @@ -151,6 +157,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -182,6 +190,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadProposedPlanRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: () => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + ORDER BY thread_id ASC, created_at ASC, plan_id ASC + `, + }); + const listThreadActivityRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadActivityDbRowSchema, @@ -195,9 +220,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { kind, summary, payload_json AS "payload", + sequence, created_at AS "createdAt" FROM projection_thread_activities - ORDER BY thread_id ASC, created_at ASC, activity_id ASC + ORDER BY + thread_id ASC, + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC `, }); @@ -212,8 +243,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { provider_name AS "providerName", provider_session_id AS "providerSessionId", provider_thread_id AS "providerThreadId", - approval_policy AS "approvalPolicy", - sandbox_mode AS "sandboxMode", + runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" @@ -282,6 +312,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectRows, threadRows, messageRows, + proposedPlanRows, activityRows, sessionRows, checkpointRows, @@ -312,6 +343,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -355,6 +394,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ]); const messagesByThread = new Map>(); + const proposedPlansByThread = new Map>(); const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); @@ -388,6 +428,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messagesByThread.set(row.threadId, threadMessages); } + for (const row of proposedPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } + for (const row of activityRows) { updatedAt = maxIso(updatedAt, row.createdAt); const threadActivities = activitiesByThread.get(row.threadId) ?? []; @@ -398,6 +451,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { summary: row.summary, payload: row.payload, turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), createdAt: row.createdAt, }); activitiesByThread.set(row.threadId, threadActivities); @@ -452,10 +506,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadId: row.threadId, status: row.status, providerName: row.providerName, - providerSessionId: row.providerSessionId, - providerThreadId: row.providerThreadId, - approvalPolicy: row.approvalPolicy, - sandboxMode: row.sandboxMode, + runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, updatedAt: row.updatedAt, @@ -478,6 +529,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectId: row.projectId, title: row.title, model: row.model, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -485,6 +538,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3abe82b544..f3b6221f6a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2,15 +2,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -19,6 +18,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../../git/Errors.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -36,17 +36,15 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { +async function waitFor(predicate: () => boolean | Promise, timeoutMs = 2000): Promise { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { - if (predicate()) { + if (await predicate()) { return; } if (Date.now() >= deadline) { @@ -88,36 +86,95 @@ describe("ProviderCommandReactor", () => { createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; - const startSession = vi.fn((_: unknown, __: unknown) => { + const runtimeSessions: Array = []; + const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; - return Effect.succeed({ - sessionId: asSessionId(`sess-${sessionIndex}`), - provider: "codex" as const, + const provider = + typeof input === "object" && + input !== null && + "provider" in input && + (input.provider === "codex" || + input.provider === "claudeCode" || + input.provider === "cursor") + ? input.provider + : "codex"; + const resumeCursor = + typeof input === "object" && input !== null && "resumeCursor" in input + ? input.resumeCursor + : undefined; + const model = + typeof input === "object" && input !== null && "model" in input && typeof input.model === "string" + ? input.model + : undefined; + const threadId = + typeof input === "object" && + input !== null && + "threadId" in input && + typeof input.threadId === "string" + ? ThreadId.makeUnsafe(input.threadId) + : ThreadId.makeUnsafe(`thread-${sessionIndex}`); + const session: ProviderSession = { + provider, status: "ready" as const, - threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), + runtimeMode: + typeof input === "object" && + input !== null && + "runtimeMode" in input && + (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") + ? input.runtimeMode + : "full-access", + ...(model !== undefined ? { model } : {}), + threadId, + resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, createdAt: now, updatedAt: now, - }); + }; + runtimeSessions.push(session); + return Effect.succeed(session); }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("provider-turn-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), }), ); const interruptTurn = vi.fn((_: unknown) => Effect.void); - const respondToRequest = vi.fn((_: unknown) => Effect.void); - const stopSession = vi.fn((_: unknown) => Effect.void); - const renameBranch = vi.fn((_: unknown) => - Effect.succeed({ - branch: "t3code/generated-name", + const respondToRequest = vi.fn(() => Effect.void); + const respondToUserInput = vi.fn(() => Effect.void); + const stopSession = vi.fn((input: unknown) => + Effect.sync(() => { + const threadId = + typeof input === "object" && input !== null && "threadId" in input + ? (input as { threadId?: ThreadId }).threadId + : undefined; + if (!threadId) { + return; + } + const index = runtimeSessions.findIndex((session) => session.threadId === threadId); + if (index >= 0) { + runtimeSessions.splice(index, 1); + } }), ); - const generateBranchName = vi.fn(() => + const renameBranch = vi.fn((input: unknown) => Effect.succeed({ - branch: "generated-name", + branch: + typeof input === "object" && + input !== null && + "newBranch" in input && + typeof input.newBranch === "string" + ? input.newBranch + : "renamed-branch", }), ); + const generateBranchName = vi.fn(() => + Effect.fail( + new TextGenerationError({ + operation: "generateBranchName", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -125,8 +182,13 @@ describe("ProviderCommandReactor", () => { sendTurn: sendTurn as ProviderServiceShape["sendTurn"], interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], + respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], - listSessions: () => Effect.succeed([]), + listSessions: () => Effect.succeed(runtimeSessions), + getCapabilities: (provider) => + Effect.succeed({ + sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session", + }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -175,6 +237,8 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt: now, @@ -187,6 +251,7 @@ describe("ProviderCommandReactor", () => { sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, renameBranch, generateBranchName, @@ -209,8 +274,8 @@ describe("ProviderCommandReactor", () => { text: "hello reactor", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); @@ -221,275 +286,294 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", model: "gpt-5-codex", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + 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?.providerSessionId).toBe("sess-1"); - expect(thread?.session?.approvalPolicy).toBe("on-request"); - expect(thread?.session?.sandboxMode).toBe("workspace-write"); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("generates and renames temporary worktree branch on first turn", async () => { + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"), - threadId: ThreadId.makeUnsafe("thread-1"), - branch: "t3code/89abc123", - worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", - }), - ); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"), + commandId: CommandId.makeUnsafe("cmd-turn-start-fast"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-worktree-rename"), + messageId: asMessageId("user-message-fast"), role: "user", - text: "Fix visual bug from screenshot", - attachments: [ - { - type: "image", - id: "thread-1-att-rename", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], + text: "hello fast mode", + attachments: [], + }, + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); - await waitFor(() => harness.renameBranch.mock.calls.length === 1); - - expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ - cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", - message: "Fix visual bug from screenshot", - attachments: [ - { - type: "image", - id: "thread-1-att-rename", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, }, - ], - }); - expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ - cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", - oldBranch: "t3code/89abc123", - newBranch: "t3code/generated-name", + }, }); - - await waitFor(() => { - const readModel = Effect.runSync(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return thread?.branch === "t3code/generated-name"; + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, }); }); - it("passes persisted attachment references to branch generation and turn start", async () => { + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); await Effect.runPromise( harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"), + type: "thread.interaction-mode.set", + commandId: CommandId.makeUnsafe("cmd-interaction-mode-set-plan"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "t3code/abcdef12", - worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", + interactionMode: "plan", + createdAt: now, }), ); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"), + commandId: CommandId.makeUnsafe("cmd-turn-start-plan"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-persisted-attachments"), + messageId: asMessageId("user-message-plan"), role: "user", - text: "Fix visual bug from screenshot", - attachments: [ - { - type: "image", - id: "thread-1-att-persisted", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], + text: "plan this change", + attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: "plan", + runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ - cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", - message: "Fix visual bug from screenshot", - attachments: [ - { - type: "image", - id: "thread-1-att-persisted", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - attachments: [ - { - type: "image", - id: "thread-1-att-persisted", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + }); + }); + + 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("skips worktree branch generation after the first user turn", async () => { + 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.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"), + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "t3code/1234abcd", - worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd", + 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(); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-first"), + commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-first"), + messageId: asMessageId("user-message-unchanged-1"), role: "user", text: "first", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + 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-second"), + commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-second"), + messageId: asMessageId("user-message-unchanged-2"), role: "user", text: "second", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.generateBranchName.mock.calls.length).toBe(1); - expect(harness.renameBranch.mock.calls.length).toBe(1); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); }); - it("skips worktree rename when branch-name generation fails", async () => { + it("reuses the same cursor session when requested model is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - harness.generateBranchName.mockImplementationOnce(() => - Effect.fail( - new TextGenerationError({ - operation: "generateBranchName", - detail: "model returned invalid payload", - }), - ), - ); await Effect.runPromise( harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"), + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "t3code/0000abcd", - worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd", + 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-null-branch"), + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-null-branch"), + messageId: asMessageId("user-message-cursor-model-same-2"), role: "user", - text: "Fix visual regression", + text: "second", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); - await Effect.runPromise(Effect.sleep("20 millis")); - expect(harness.renameBranch.mock.calls.length).toBe(0); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.branch).toBe("t3code/0000abcd"); + 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("reuses the same provider session when runtime mode is unchanged", async () => { + 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-unchanged-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-unchanged-1"), + messageId: asMessageId("user-message-cursor-model-change-1"), role: "user", text: "first", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + provider: "cursor", + model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); @@ -500,29 +584,46 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-unchanged-2"), + messageId: asMessageId("user-message-cursor-model-change-2"), role: "user", text: "second", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + 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); + 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 changes", async () => { + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -534,8 +635,8 @@ describe("ProviderCommandReactor", () => { text: "first", attachments: [], }, - approvalPolicy: "never", - sandboxMode: "danger-full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", createdAt: now, }), ); @@ -543,6 +644,22 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + return thread?.runtimeMode === "approval-required"; + }); + await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -554,50 +671,47 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", createdAt: now, }), ); - await waitFor(() => harness.stopSession.mock.calls.length === 1); - await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); + expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + threadId: ThreadId.makeUnsafe("thread-1"), + resumeCursor: { opaque: "cursor-1" }, + runtimeMode: "approval-required", }); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - sessionId: asSessionId("sess-2"), + threadId: ThreadId.makeUnsafe("thread-1"), }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-2"); - expect(thread?.session?.approvalPolicy).toBe("on-request"); - expect(thread?.session?.sandboxMode).toBe("workspace-write"); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("does not stop the active session when restart fails before rebind", async () => { + 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-restart-failure-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-restart-failure-1"), + messageId: asMessageId("user-message-provider-switch-1"), role: "user", text: "first", attachments: [], }, - approvalPolicy: "never", - sandboxMode: "danger-full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); @@ -605,27 +719,95 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - harness.startSession.mockImplementationOnce( - (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, + 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(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), ); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"), + commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-restart-failure-2"), + messageId: asMessageId("user-message-restart-failure-1"), role: "user", - text: "second", + text: "first", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-restart-failure"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + return thread?.runtimeMode === "approval-required"; + }); await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise(Effect.sleep("30 millis")); @@ -634,9 +816,8 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-1"); - expect(thread?.session?.approvalPolicy).toBe("never"); - expect(thread?.session?.sandboxMode).toBe("danger-full-access"); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.runtimeMode).toBe("full-access"); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { @@ -652,10 +833,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, updatedAt: now, @@ -676,7 +854,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.interruptTurn.mock.calls.length === 1); expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ - sessionId: "sess-1", + threadId: "thread-1", }); }); @@ -693,10 +871,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: now, @@ -718,12 +893,149 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.respondToRequest.mock.calls.length === 1); expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ - sessionId: "sess-1", + threadId: "thread-1", requestId: "approval-request-1", decision: "accept", }); }); + it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.makeUnsafe("cmd-user-input-respond"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + sandbox_mode: "workspace-write", + }, + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToUserInput.mock.calls.length === 1); + expect(harness.respondToUserInput.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "user-input-request-1", + answers: { + sandbox_mode: "workspace-write", + }, + }); + }); + + it("surfaces stale provider approval request failures without faking approval resolution", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.respondToRequest.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/request_permission", + detail: "Unknown pending permission request: approval-request-1", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-for-approval-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "running", + providerName: "cursor", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.makeUnsafe("cmd-approval-requested"), + threadId: ThreadId.makeUnsafe("thread-1"), + activity: { + id: EventId.makeUnsafe("activity-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-1", + requestKind: "command", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-approval-respond-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "acceptForSession", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + if (!thread) return false; + return thread.activities.some((activity) => activity.kind === "provider.approval.respond.failed"); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.approval.respond.failed", + ); + expect(failureActivity).toBeDefined(); + expect(failureActivity?.payload).toMatchObject({ + requestId: "approval-request-1", + }); + + const resolvedActivity = thread?.activities.find( + (activity) => + activity.kind === "approval.resolved" && + typeof activity.payload === "object" && + activity.payload !== null && + (activity.payload as Record).requestId === "approval-request-1", + ); + expect(resolvedActivity).toBeUndefined(); + }); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -737,10 +1049,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, lastError: null, updatedAt: now, @@ -763,8 +1072,7 @@ describe("ProviderCommandReactor", () => { const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.session).not.toBeNull(); expect(thread?.session?.status).toBe("stopped"); - expect(thread?.session?.providerSessionId).toBeNull(); - expect(thread?.session?.providerThreadId).toBeNull(); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.activeTurnId).toBeNull(); }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bf88e221b9..0b5967480a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,19 +3,19 @@ import { CommandId, EventId, type OrchestrationEvent, - type ProviderApprovalPolicy, + type ProviderModelOptions, type ProviderKind, - type ProviderSandboxMode, type OrchestrationSession, - type ThreadId, + ThreadId, type ProviderSession, - type ProviderThreadId, + type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; +import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Schema, Stream } from "effect"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -28,9 +28,11 @@ type ProviderIntentEvent = Extract< OrchestrationEvent, { type: + | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" | "thread.approval-response-requested" + | "thread.user-input-response-requested" | "thread.session-stop-requested"; } >; @@ -66,11 +68,36 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); -const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; -const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; +const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function isUnknownPendingApprovalRequestError(error: unknown): boolean { + if (Schema.is(ProviderAdapterRequestError)(error)) { + const detail = error.detail.toLowerCase(); + return ( + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); + } + const message = toErrorMessage(error).toLowerCase(); + return ( + message.includes("unknown pending approval request") || + message.includes("unknown pending permission request") + ); +} + function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } @@ -122,11 +149,13 @@ const make = Effect.gen(function* () { | "provider.turn.start.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" + | "provider.user-input.respond.failed" | "provider.session.stop.failed"; readonly summary: string; readonly detail: string; readonly turnId: TurnId | null; readonly createdAt: string; + readonly requestId?: string; }) => orchestrationEngine.dispatch({ type: "thread.activity.append", @@ -139,6 +168,7 @@ const make = Effect.gen(function* () { summary: input.summary, payload: { detail: input.detail, + ...(input.requestId ? { requestId: input.requestId } : {}), }, turnId: input.turnId, createdAt: input.createdAt, @@ -168,8 +198,9 @@ const make = Effect.gen(function* () { threadId: ThreadId, createdAt: string, options?: { - readonly approvalPolicy?: ProviderApprovalPolicy; - readonly sandboxMode?: ProviderSandboxMode; + readonly provider?: ProviderKind; + readonly model?: string; + readonly modelOptions?: ProviderModelOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -178,27 +209,39 @@ const make = Effect.gen(function* () { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } - const desiredApprovalPolicy = - options?.approvalPolicy ?? thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY; - const desiredSandboxMode = - options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; - const preferredProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" + const desiredRuntimeMode = thread.runtimeMode; + const currentProvider: ProviderKind | undefined = + thread.session?.providerName === "codex" || + thread.session?.providerName === "claudeCode" || + thread.session?.providerName === "cursor" ? thread.session.providerName : undefined; + const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; + const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, }); - const startProviderSession = (resumeThreadId?: ProviderThreadId | null) => + const resolveActiveSession = (threadId: ThreadId) => + providerService.listSessions().pipe( + Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)), + ); + + const startProviderSession = (input?: { + readonly resumeCursor?: unknown; + readonly provider?: ProviderKind; + }) => providerService.startSession(threadId, { - ...(preferredProvider ? { provider: preferredProvider } : {}), + threadId, + ...(input?.provider ?? preferredProvider + ? { provider: input?.provider ?? preferredProvider } + : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.model ? { model: thread.model } : {}), - ...(resumeThreadId ? { resumeThreadId } : {}), - approvalPolicy: desiredApprovalPolicy, - sandboxMode: desiredSandboxMode, + ...(desiredModel ? { model: desiredModel } : {}), + ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, }); const bindSessionToThread = (session: ProviderSession) => @@ -208,10 +251,7 @@ const make = Effect.gen(function* () { threadId, status: mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, - providerSessionId: session.sessionId, - providerThreadId: session.threadId ?? null, - approvalPolicy: desiredApprovalPolicy, - sandboxMode: desiredSandboxMode, + runtimeMode: desiredRuntimeMode, // Provider turn ids are not orchestration turn ids. activeTurnId: null, lastError: session.lastError ?? null, @@ -220,66 +260,102 @@ const make = Effect.gen(function* () { createdAt, }); - const existingSessionId = thread.session?.providerSessionId; - if (existingSessionId) { - const approvalPolicyChanged = - options?.approvalPolicy !== undefined && - options.approvalPolicy !== thread.session?.approvalPolicy; - const sandboxModeChanged = - options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; - - if (!approvalPolicyChanged && !sandboxModeChanged) { - return existingSessionId; + const existingSessionThreadId = + thread.session && thread.session.status !== "stopped" ? thread.id : null; + if (existingSessionThreadId) { + const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; + const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; + const activeSession = yield* resolveActiveSession(existingSessionThreadId); + const sessionModelSwitch = + currentProvider === undefined + ? "in-session" + : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; + const modelChanged = + options?.model !== undefined && options.model !== activeSession?.model; + const shouldRestartForModelChange = + modelChanged && sessionModelSwitch === "restart-session"; + + if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + return existingSessionThreadId; } - const restartedSession = yield* startProviderSession( - thread.session?.providerThreadId ?? null, - ); + const resumeCursor = + providerChanged || shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); + yield* Effect.logInfo("provider command reactor restarting provider session", { + threadId, + existingSessionThreadId, + currentProvider, + desiredProvider: options?.provider ?? currentProvider, + currentRuntimeMode: thread.session?.runtimeMode, + desiredRuntimeMode: thread.runtimeMode, + runtimeModeChanged, + providerChanged, + modelChanged, + shouldRestartForModelChange, + hasResumeCursor: resumeCursor !== undefined, + }); + const restartedSession = yield* startProviderSession({ + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + ...(options?.provider !== undefined ? { provider: options.provider } : {}), + }); + yield* Effect.logInfo("provider command reactor restarted provider session", { + threadId, + previousSessionId: existingSessionThreadId, + restartedSessionThreadId: restartedSession.threadId, + provider: restartedSession.provider, + runtimeMode: restartedSession.runtimeMode, + }); yield* bindSessionToThread(restartedSession); - yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to stop superseded provider session", { - threadId, - sessionId: existingSessionId, - cause: Cause.pretty(cause), - }), - ), - ); - return restartedSession.sessionId; + return restartedSession.threadId; } - const startedSession = yield* startProviderSession(); + const startedSession = yield* startProviderSession( + options?.provider !== undefined ? { provider: options.provider } : undefined, + ); yield* bindSessionToThread(startedSession); - return startedSession.sessionId; + return startedSession.threadId; }); const sendTurnForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly provider?: ProviderKind; readonly model?: string; - readonly effort?: string; - readonly approvalPolicy: ProviderApprovalPolicy; - readonly sandboxMode: ProviderSandboxMode; + readonly modelOptions?: ProviderModelOptions; + readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); if (!thread) { return; } - const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { - approvalPolicy: input.approvalPolicy, - sandboxMode: input.sandboxMode, + yield* ensureSessionForThread(input.threadId, input.createdAt, { + ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + const activeSession = yield* providerService.listSessions().pipe( + Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), + ); + const sessionModelSwitch = + activeSession === undefined + ? "in-session" + : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; + const modelForTurn = + sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; yield* providerService.sendTurn({ - sessionId, + threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), - ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), + ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); }); @@ -390,10 +466,10 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), - ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), - approvalPolicy: event.payload.approvalPolicy, - sandboxMode: event.payload.sandboxMode, + ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); }); @@ -405,8 +481,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const sessionId = thread.session?.providerSessionId; - if (!sessionId) { + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -418,7 +494,7 @@ const make = Effect.gen(function* () { } // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ sessionId }); + yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); const processApprovalResponseRequested = Effect.fnUntraced(function* ( @@ -428,8 +504,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const sessionId = thread.session?.providerSessionId; - if (!sessionId) { + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.approval.respond.failed", @@ -437,14 +513,79 @@ const make = Effect.gen(function* () { detail: "No active provider session is bound to this thread.", turnId: null, createdAt: event.payload.createdAt, + requestId: event.payload.requestId, }); } - yield* providerService.respondToRequest({ - sessionId, - requestId: event.payload.requestId, - decision: event.payload.decision, - }); + yield* providerService + .respondToRequest({ + threadId: event.payload.threadId, + requestId: event.payload.requestId, + decision: event.payload.decision, + }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + const detail = toErrorMessage(error); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + + if (!isUnknownPendingApprovalRequestError(error)) return; + }), + ), + ); + }); + + const processUserInputResponseRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + } + + yield* providerService + .respondToUserInput({ + threadId: event.payload.threadId, + requestId: event.payload.requestId, + answers: event.payload.answers, + }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + detail: toErrorMessage(error), + turnId: null, + createdAt: event.payload.createdAt, + requestId: event.payload.requestId, + }); + }), + ), + ); }); const processSessionStopRequested = Effect.fnUntraced(function* ( @@ -456,10 +597,8 @@ const make = Effect.gen(function* () { } const now = event.payload.createdAt; - const sessionId = thread.session?.providerSessionId; - - if (sessionId) { - yield* providerService.stopSession({ sessionId }); + if (thread.session && thread.session.status !== "stopped") { + yield* providerService.stopSession({ threadId: thread.id }); } yield* setThreadSession({ @@ -468,10 +607,7 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "stopped", providerName: thread.session?.providerName ?? null, - providerSessionId: null, - providerThreadId: null, - approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, - sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, + runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, updatedAt: now, @@ -483,6 +619,14 @@ const make = Effect.gen(function* () { const processDomainEvent = (event: ProviderIntentEvent) => Effect.gen(function* () { switch (event.type) { + case "thread.runtime-mode-set": { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread?.session || thread.session.status === "stopped") { + return; + } + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt); + return; + } case "thread.turn-start-requested": yield* processTurnStartRequested(event); return; @@ -492,6 +636,9 @@ const make = Effect.gen(function* () { case "thread.approval-response-requested": yield* processApprovalResponseRequested(event); return; + case "thread.user-input-response-requested": + yield* processUserInputResponseRequested(event); + return; case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; @@ -522,9 +669,11 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( + event.type !== "thread.runtime-mode-set" && event.type !== "thread.turn-start-requested" && event.type !== "thread.turn-interrupt-requested" && event.type !== "thread.approval-response-requested" && + event.type !== "thread.user-input-response-requested" && event.type !== "thread.session-stop-requested" ) { return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 55a8452be8..aa10ff66bc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,14 +1,14 @@ -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; import { + ApprovalRequestId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, ProjectId, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it } from "vitest"; @@ -32,12 +32,24 @@ import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); + +type LegacyProviderRuntimeEvent = { + readonly type: string; + readonly eventId: EventId; + readonly provider: "codex" | "claudeCode" | "cursor"; + readonly createdAt: string; + readonly threadId: ThreadId; + readonly turnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly payload?: unknown | undefined; + readonly [key: string]: unknown; +}; function createProviderServiceHarness() { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -48,15 +60,17 @@ function createProviderServiceHarness() { sendTurn: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; - const emit = (event: ProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); + const emit = (event: LegacyProviderRuntimeEvent): void => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); }; return { @@ -67,19 +81,11 @@ function createProviderServiceHarness() { async function waitForThread( engine: OrchestrationEngineShape, - predicate: (thread: { - session: { status: string; activeTurnId: string | null; lastError: string | null } | null; - messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; - activities: ReadonlyArray<{ kind: string }>; - }) => boolean, + predicate: (thread: ProviderRuntimeTestThread) => boolean, timeoutMs = 2000, ) { const deadline = Date.now() + timeoutMs; - const poll = async (): Promise<{ - session: { status: string; activeTurnId: string | null; lastError: string | null } | null; - messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; - activities: ReadonlyArray<{ kind: string }>; - }> => { + const poll = async (): Promise => { const readModel = await Effect.runPromise(engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); if (thread && predicate(thread)) { @@ -94,6 +100,13 @@ async function waitForThread( return poll(); } +type ProviderRuntimeTestReadModel = OrchestrationReadModel; +type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]; +type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number]; +type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number]; +type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number]; +type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number]; + describe("ProviderRuntimeIngestion", () => { let runtime: ManagedRuntime.ManagedRuntime< OrchestrationEngineService | ProviderRuntimeIngestionService, @@ -153,6 +166,8 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -167,10 +182,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: null, updatedAt: createdAt, lastError: null, @@ -193,9 +205,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: now, - turnId: asProviderTurnId("turn-1"), + turnId: asTurnId("turn-1"), }); await waitForThread( @@ -207,11 +219,13 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-1"), - status: "failed", - errorMessage: "turn failed", + turnId: asTurnId("turn-1"), + payload: { + state: "failed", + errorMessage: "turn failed", + }, }); const thread = await waitForThread( @@ -225,6 +239,205 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("applies provider session.state.changed transitions directly", async () => { + const harness = await createHarness(); + const waitingAt = new Date().toISOString(); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-waiting"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: waitingAt, + payload: { + state: "waiting", + reason: "awaiting approval", + }, + }); + + let thread = await waitForThread( + harness.engine, + (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null, + ); + expect(thread.session?.status).toBe("running"); + expect(thread.session?.lastError).toBeNull(); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-error"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "error", + reason: "provider crashed", + }, + }); + + thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "error" && + entry.session?.activeTurnId === null && + entry.session?.lastError === "provider crashed", + ); + expect(thread.session?.status).toBe("error"); + expect(thread.session?.lastError).toBe("provider crashed"); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-stopped"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "stopped", + }, + }); + + thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "stopped" && + entry.session?.activeTurnId === null && + entry.session?.lastError === "provider crashed", + ); + expect(thread.session?.status).toBe("stopped"); + expect(thread.session?.lastError).toBe("provider crashed"); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-state-ready"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "ready", + }, + }); + + thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.session?.lastError === null, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.lastError).toBeNull(); + }); + + it("does not clear active turn when session/thread started arrives mid-turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-midturn-lifecycle"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-midturn-lifecycle"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-midturn-lifecycle", + ); + + harness.emit({ + type: "thread.started", + eventId: asEventId("evt-thread-started-midturn-lifecycle"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + }); + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started-midturn-lifecycle"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + }); + + 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-midturn-lifecycle"); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-midturn-lifecycle"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-midturn-lifecycle"), + status: "completed", + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + + 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 auxiliary turn completions from a different provider thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -233,10 +446,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-primary"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-primary"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-primary"), }); await waitForThread( @@ -249,10 +461,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-aux"), - turnId: asProviderTurnId("turn-aux"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-aux"), status: "completed", }); @@ -268,10 +479,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-primary"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-primary"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-primary"), status: "completed", }); @@ -289,9 +499,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-guarded"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-guarded-main"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-main"), }); await waitForThread( @@ -305,9 +515,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-other"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-guarded-other"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-other"), status: "completed", }); @@ -323,9 +533,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-main"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-guarded-main"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-main"), status: "completed", }); @@ -335,48 +545,188 @@ describe("ProviderRuntimeIngestion", () => { ); }); - it("maps message delta/completed into finalized assistant messages", async () => { + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = new Date().toISOString(); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-1"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), - delta: "hello", + payload: { + streamKind: "assistant_text", + delta: "hello", + }, }); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-2"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), - delta: " world", + payload: { + streamKind: "assistant_text", + delta: " world", + }, }); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); const thread = await waitForThread(harness.engine, (entry) => - entry.messages.some((message) => message.id === "assistant:item-1" && !message.streaming), + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-1" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-1", ); - const message = thread.messages.find((entry) => entry.id === "assistant:item-1"); expect(message?.text).toBe("hello world"); expect(message?.streaming).toBe(false); }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-item-completed-no-delta"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-no-delta"), + itemId: asItemId("item-no-delta"), + payload: { + itemType: "assistant_message", + status: "completed", + detail: "assistant-only final text", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-no-delta" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-no-delta", + ); + expect(message?.text).toBe("assistant-only final text"); + expect(message?.streaming).toBe(false); + }); + + it("projects completed plan items into first-class proposed plans", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-item-completed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-final"), + payload: { + planMarkdown: "## Ship plan\n\n- wire projection\n- render follow-up", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-1:turn:turn-plan-final", + ), + ); + const proposedPlan = thread.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final", + ); + expect(proposedPlan?.planMarkdown).toBe("## Ship plan\n\n- wire projection\n- render follow-up"); + }); + + it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-plan-buffer"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer", + ); + + harness.emit({ + type: "turn.proposed.delta", + eventId: asEventId("evt-plan-delta-1"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + payload: { + delta: "## Buffered plan\n\n- first", + }, + }); + harness.emit({ + type: "turn.proposed.delta", + eventId: asEventId("evt-plan-delta-2"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + payload: { + delta: "\n- second", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-plan-buffer"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plan-buffer"), + payload: { + state: "completed", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer", + ), + ); + const proposedPlan = thread.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer", + ); + expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); + }); + it("buffers assistant deltas by default until completion", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -385,9 +735,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), }); await waitForThread( harness.engine, @@ -396,14 +746,17 @@ describe("ProviderRuntimeIngestion", () => { ); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), itemId: asItemId("item-buffered"), - delta: "buffer me", + payload: { + streamKind: "assistant_text", + delta: "buffer me", + }, }); await Effect.runPromise(Effect.sleep("30 millis")); @@ -411,26 +764,35 @@ describe("ProviderRuntimeIngestion", () => { const midThread = midReadModel.threads.find( (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), ); - expect(midThread?.messages.some((message) => message.id === "assistant:item-buffered")).toBe( - false, - ); + expect( + midThread?.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffered", + ), + ).toBe(false); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), itemId: asItemId("item-buffered"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); const thread = await waitForThread(harness.engine, (entry) => entry.messages.some( - (message) => message.id === "assistant:item-buffered" && !message.streaming, + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered" && !message.streaming, ), ); - const message = thread.messages.find((entry) => entry.id === "assistant:item-buffered"); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered", + ); expect(message?.text).toBe("buffer me"); expect(message?.streaming).toBe(false); }); @@ -451,8 +813,8 @@ describe("ProviderRuntimeIngestion", () => { attachments: [], }, assistantDeliveryMode: "streaming", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); @@ -462,9 +824,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), }); await waitForThread( harness.engine, @@ -474,46 +836,54 @@ describe("ProviderRuntimeIngestion", () => { ); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), itemId: asItemId("item-streaming-mode"), - delta: "hello live", + payload: { + streamKind: "assistant_text", + delta: "hello live", + }, }); const liveThread = await waitForThread(harness.engine, (entry) => entry.messages.some( - (message) => + (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-streaming-mode" && message.streaming && message.text === "hello live", ), ); const liveMessage = liveThread.messages.find( - (entry) => entry.id === "assistant:item-streaming-mode", + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-mode", ); expect(liveMessage?.streaming).toBe(true); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), itemId: asItemId("item-streaming-mode"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); const finalThread = await waitForThread(harness.engine, (entry) => entry.messages.some( - (message) => message.id === "assistant:item-streaming-mode" && !message.streaming, + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-mode" && !message.streaming, ), ); const finalMessage = finalThread.messages.find( - (entry) => entry.id === "assistant:item-streaming-mode", + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-mode", ); expect(finalMessage?.text).toBe("hello live"); expect(finalMessage?.streaming).toBe(false); @@ -528,9 +898,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), }); await waitForThread( harness.engine, @@ -540,37 +910,47 @@ describe("ProviderRuntimeIngestion", () => { ); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), itemId: asItemId("item-buffer-spill"), - delta: oversizedText, + payload: { + streamKind: "assistant_text", + delta: oversizedText, + }, }); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), itemId: asItemId("item-buffer-spill"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); const thread = await waitForThread(harness.engine, (entry) => entry.messages.some( - (message) => message.id === "assistant:item-buffer-spill" && !message.streaming, + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffer-spill" && !message.streaming, ), ); - const message = thread.messages.find((entry) => entry.id === "assistant:item-buffer-spill"); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffer-spill", + ); expect(message?.text.length).toBe(oversizedText.length); expect(message?.text).toBe(oversizedText); expect(message?.streaming).toBe(false); }); - it("does not duplicate assistant completion when message.completed is followed by turn.completed", async () => { + it("does not duplicate assistant completion when item.completed is followed by turn.completed", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -578,9 +958,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), }); await waitForThread( @@ -591,32 +971,41 @@ describe("ProviderRuntimeIngestion", () => { ); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), itemId: asItemId("item-complete-dedup"), - delta: "done", + payload: { + streamKind: "assistant_text", + delta: "done", + }, }); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), itemId: asItemId("item-complete-dedup"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), - status: "completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), + payload: { + state: "completed", + }, }); await waitForThread( @@ -625,7 +1014,8 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.status === "ready" && thread.session?.activeTurnId === null && thread.messages.some( - (message) => message.id === "assistant:item-complete-dedup" && !message.streaming, + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-complete-dedup" && !message.streaming, ), ); @@ -646,6 +1036,72 @@ describe("ProviderRuntimeIngestion", () => { expect(completionEvents).toHaveLength(1); }); + it("maps canonical request events into approval activities with requestKind", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + requestId: ApprovalRequestId.makeUnsafe("req-open"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + harness.emit({ + type: "request.resolved", + eventId: asEventId("evt-request-resolved"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + requestId: ApprovalRequestId.makeUnsafe("req-open"), + payload: { + requestType: "command_execution_approval", + decision: "accept", + }, + }); + + await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.resolved", + ), + ); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const requested = thread?.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-opened", + ); + const requestedPayload = + requested?.payload && typeof requested.payload === "object" + ? (requested.payload as Record) + : undefined; + expect(requestedPayload?.requestKind).toBe("command"); + expect(requestedPayload?.requestType).toBe("command_execution_approval"); + + const resolved = thread?.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved", + ); + const resolvedPayload = + resolved?.payload && typeof resolved.payload === "object" + ? (resolved.payload as Record) + : undefined; + expect(resolvedPayload?.requestKind).toBe("command"); + expect(resolvedPayload?.requestType).toBe("command_execution_approval"); + }); + it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -654,10 +1110,12 @@ describe("ProviderRuntimeIngestion", () => { type: "runtime.error", eventId: asEventId("evt-runtime-error"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-3"), - message: "runtime exploded", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-3"), + payload: { + message: "runtime exploded", + }, }); const thread = await waitForThread( @@ -671,7 +1129,7 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime exploded"); }); - it("maps session/thread lifecycle and tool.started into session/activity projections", async () => { + it("maps session/thread lifecycle and item.started into session/activity projections", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -679,29 +1137,30 @@ describe("ProviderRuntimeIngestion", () => { type: "session.started", eventId: asEventId("evt-session-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + threadId: asThreadId("thread-1"), message: "session started", }); harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), + threadId: asThreadId("thread-1"), }); harness.emit({ - type: "tool.started", + type: "item.started", eventId: asEventId("evt-tool-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-9"), - toolKind: "other", - title: "Read file", - detail: "/tmp/file.ts", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), + payload: { + itemType: "command_execution", + status: "in_progress", + title: "Read file", + detail: "/tmp/file.ts", + }, }); const thread = await waitForThread( @@ -709,11 +1168,325 @@ describe("ProviderRuntimeIngestion", () => { (entry) => entry.session?.status === "ready" && entry.session?.activeTurnId === null && - entry.activities.some((activity) => activity.kind === "tool.started"), + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), ); expect(thread.session?.status).toBe("ready"); - expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), + ).toBe(true); + }); + + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-updated"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + harness.emit({ + type: "turn.plan.updated", + eventId: asEventId("evt-turn-plan-updated"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + payload: { + explanation: "Working through the plan", + plan: [ + { step: "Inspect files", status: "completed" }, + { step: "Apply patch", status: "in_progress" }, + ], + }, + }); + + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-item-updated"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + itemId: asItemId("item-p1-tool"), + payload: { + itemType: "command_execution", + status: "in_progress", + title: "Run tests", + detail: "bun test", + data: { pid: 123 }, + }, + }); + + harness.emit({ + type: "runtime.warning", + eventId: asEventId("evt-runtime-warning"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + payload: { + message: "Provider got slow", + detail: { latencyMs: 1500 }, + }, + }); + + harness.emit({ + type: "turn.diff.updated", + eventId: asEventId("evt-turn-diff-updated"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + itemId: asItemId("item-p1-assistant"), + payload: { + unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.title === "Renamed by provider" && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning", + ) && + entry.checkpoints.some( + (checkpoint: ProviderRuntimeTestCheckpoint) => checkpoint.turnId === "turn-p1", + ), + ); + + expect(thread.title).toBe("Renamed by provider"); + + const planActivity = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", + ); + const planPayload = + planActivity?.payload && typeof planActivity.payload === "object" + ? (planActivity.payload as Record) + : undefined; + expect(planActivity?.kind).toBe("turn.plan.updated"); + expect(Array.isArray(planPayload?.plan)).toBe(true); + + const toolUpdate = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-item-updated", + ); + const toolUpdatePayload = + toolUpdate?.payload && typeof toolUpdate.payload === "object" + ? (toolUpdate.payload as Record) + : undefined; + expect(toolUpdate?.kind).toBe("tool.updated"); + expect(toolUpdatePayload?.itemType).toBe("command_execution"); + expect(toolUpdatePayload?.status).toBe("in_progress"); + + const warning = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning", + ); + const warningPayload = + warning?.payload && typeof warning.payload === "object" + ? (warning.payload as Record) + : undefined; + expect(warning?.kind).toBe("runtime.warning"); + expect(warningPayload?.message).toBe("Provider got slow"); + + const checkpoint = thread.checkpoints.find( + (entry: ProviderRuntimeTestCheckpoint) => entry.turnId === "turn-p1", + ); + expect(checkpoint?.status).toBe("missing"); + expect(checkpoint?.assistantMessageId).toBe("assistant:item-p1-assistant"); + expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); + }); + + it("projects Codex task lifecycle chunks into thread activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "task.started", + eventId: asEventId("evt-task-started"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + taskType: "plan", + }, + }); + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-task-progress"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + description: "Comparing the desktop rollout chunks to the app-server stream.", + }, + }); + + harness.emit({ + type: "task.completed", + eventId: asEventId("evt-task-completed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + taskId: "turn-task-1", + status: "completed", + summary: "\n# Plan title\n", + }, + }); + harness.emit({ + type: "turn.proposed.completed", + eventId: asEventId("evt-task-proposed-plan-completed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-task-1"), + payload: { + planMarkdown: "# Plan title", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", + ) && + entry.proposedPlans.some( + (proposedPlan: ProviderRuntimeTestProposedPlan) => + proposedPlan.id === "plan:thread-1:turn:turn-task-1", + ), + ); + + const started = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", + ); + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", + ); + const completed = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", + ); + + const progressPayload = + progress?.payload && typeof progress.payload === "object" + ? (progress.payload as Record) + : undefined; + const completedPayload = + completed?.payload && typeof completed.payload === "object" + ? (completed.payload as Record) + : undefined; + + expect(started?.kind).toBe("task.started"); + expect(started?.summary).toBe("Plan task started"); + expect(progress?.kind).toBe("task.progress"); + expect(progressPayload?.detail).toBe( + "Comparing the desktop rollout chunks to the app-server stream.", + ); + expect(completed?.kind).toBe("task.completed"); + expect(completedPayload?.detail).toBe("\n# Plan title\n"); + expect( + thread.proposedPlans.find( + (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-task-1", + )?.planMarkdown, + ).toBe("# Plan title"); + }); + + it("projects structured user input request and resolution as thread activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "user-input.requested", + eventId: asEventId("evt-user-input-requested"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-user-input"), + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }); + + harness.emit({ + type: "user-input.resolved", + eventId: asEventId("evt-user-input-resolved"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-user-input"), + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + answers: { + sandbox_mode: "workspace-write", + }, + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested", + ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.resolved", + ), + ); + + const requested = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-requested", + ); + expect(requested?.kind).toBe("user-input.requested"); + + const resolved = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-resolved", + ); + const resolvedPayload = + resolved?.payload && typeof resolved.payload === "object" + ? (resolved.payload as Record) + : undefined; + expect(resolved?.kind).toBe("user-input.resolved"); + expect(resolvedPayload?.answers).toEqual({ + sandbox_mode: "workspace-write", + }); }); it("continues processing runtime events after a single event handler failure", async () => { @@ -721,24 +1494,29 @@ describe("ProviderRuntimeIngestion", () => { const now = new Date().toISOString(); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-invalid-delta"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-invalid"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-invalid"), itemId: asItemId("item-invalid"), - delta: undefined, + payload: { + streamKind: "assistant_text", + delta: undefined, + }, } as unknown as ProviderRuntimeEvent); harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error-after-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-after-failure"), - message: "runtime still processed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-after-failure"), + payload: { + message: "runtime still processed", + }, }); const thread = await waitForThread( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 6668f5e5b3..04f6d85234 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,16 +1,14 @@ import { + ApprovalRequestId, type AssistantDeliveryMode, CommandId, MessageId, type OrchestrationEvent, - type ProviderApprovalPolicy, - type ProviderSandboxMode, - ProviderThreadId, - type ThreadId, + CheckpointRef, + ThreadId, TurnId, type OrchestrationThreadActivity, type ProviderRuntimeEvent, - type ProviderSessionId, } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; @@ -21,17 +19,17 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; -const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; +const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; -const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "on-request"; -const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); +const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000; +const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; @@ -50,12 +48,12 @@ type RuntimeIngestionInput = event: TurnStartRequestedDomainEvent; }; -function toTurnId(value: string | undefined): TurnId | undefined { - return value === undefined ? undefined : TurnId.makeUnsafe(value); +function toTurnId(value: TurnId | string | undefined): TurnId | undefined { + return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); } -function toProviderThreadId(value: string | undefined): ProviderThreadId | null { - return value === undefined ? null : ProviderThreadId.makeUnsafe(value); +function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { + return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value); } function sameId(left: string | null | undefined, right: string | null | undefined): boolean { @@ -69,11 +67,136 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } +function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined { + const trimmed = planMarkdown?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed; +} + +function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { + return `plan:${threadId}:turn:${turnId}`; +} + +function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId): string { + const turnId = toTurnId(event.turnId); + if (turnId) { + return proposedPlanIdForTurn(threadId, turnId); + } + if (event.itemId) { + return `plan:${threadId}:item:${event.itemId}`; + } + return `plan:${threadId}:event:${event.eventId}`; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { + const payload = (event as { payload?: unknown }).payload; + if (!payload || typeof payload !== "object") { + return undefined; + } + return payload as Record; +} + +function normalizeRuntimeTurnState( + value: string | undefined, +): "completed" | "failed" | "interrupted" | "cancelled" { + switch (value) { + case "failed": + case "interrupted": + case "cancelled": + case "completed": + return value; + default: + return "completed"; + } +} + +function runtimeTurnState( + event: ProviderRuntimeEvent, +): "completed" | "failed" | "interrupted" | "cancelled" { + const payloadState = asString(runtimePayloadRecord(event)?.state); + return normalizeRuntimeTurnState(payloadState); +} + +function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined { + const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage); + return payloadErrorMessage; +} + +function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined { + const payloadMessage = asString(runtimePayloadRecord(event)?.message); + return payloadMessage; +} + +function orchestrationSessionStatusFromRuntimeState( + state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", +): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { + switch (state) { + case "starting": + return "starting"; + case "running": + case "waiting": + return "running"; + case "ready": + return "ready"; + case "interrupted": + return "interrupted"; + case "stopped": + return "stopped"; + case "error": + return "error"; + } +} + +function requestKindFromCanonicalRequestType( + requestType: string | undefined, +): "command" | "file-read" | "file-change" | undefined { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + default: + return undefined; + } +} + +function isToolLifecycleItemType(itemType: string): boolean { + return ( + itemType === "command_execution" || + itemType === "file_change" || + itemType === "mcp_tool_call" || + itemType === "dynamic_tool_call" || + itemType === "collab_agent_tool_call" || + itemType === "web_search" || + itemType === "image_view" + ); +} + function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { + const maybeSequence = (() => { + const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; + return eventWithSequence.sessionSequence !== undefined + ? { sequence: eventWithSequence.sessionSequence } + : {}; + })(); switch (event.type) { - case "approval.requested": { + case "request.opened": { + if (event.payload.requestType === "tool_user_input") { + return []; + } + const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { id: event.eventId, @@ -81,20 +204,30 @@ function runtimeEventToActivities( tone: "approval", kind: "approval.requested", summary: - event.requestKind === "command" + requestKind === "command" ? "Command approval requested" - : "File-change approval requested", + : requestKind === "file-read" + ? "File-read approval requested" + : requestKind === "file-change" + ? "File-change approval requested" + : "Approval requested", payload: { - requestId: event.requestId, - requestKind: event.requestKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + requestId: toApprovalRequestId(event.requestId), + ...(requestKind ? { requestKind } : {}), + requestType: event.payload.requestType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, }, ]; } - case "approval.resolved": { + case "request.resolved": { + if (event.payload.requestType === "tool_user_input") { + return []; + } + const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { id: event.eventId, @@ -103,16 +236,22 @@ function runtimeEventToActivities( kind: "approval.resolved", summary: "Approval resolved", payload: { - requestId: event.requestId, - ...(event.requestKind ? { requestKind: event.requestKind } : {}), - ...(event.decision ? { decision: event.decision } : {}), + requestId: toApprovalRequestId(event.requestId), + ...(requestKind ? { requestKind } : {}), + requestType: event.payload.requestType, + ...(event.payload.decision ? { decision: event.payload.decision } : {}), }, turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, }, ]; } case "runtime.error": { + const message = runtimeErrorMessageFromEvent(event); + if (!message) { + return []; + } return [ { id: event.eventId, @@ -121,50 +260,225 @@ function runtimeEventToActivities( kind: "runtime.error", summary: "Runtime error", payload: { - message: truncateDetail(event.message), + message: truncateDetail(message), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "runtime.warning": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "runtime.warning", + summary: "Runtime warning", + payload: { + message: truncateDetail(event.payload.message), + ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "turn.plan.updated": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "turn.plan.updated", + summary: "Plan updated", + payload: { + plan: event.payload.plan, + ...(event.payload.explanation !== undefined ? { explanation: event.payload.explanation } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "user-input.requested": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + ...(event.requestId ? { requestId: event.requestId } : {}), + questions: event.payload.questions, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "user-input.resolved": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "user-input.resolved", + summary: "User input submitted", + payload: { + ...(event.requestId ? { requestId: event.requestId } : {}), + answers: event.payload.answers, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.started": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "task.started", + summary: + event.payload.taskType === "plan" + ? "Plan task started" + : event.payload.taskType + ? `${event.payload.taskType} task started` + : "Task started", + payload: { + taskId: event.payload.taskId, + ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), + ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), }, turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, }, ]; } - case "tool.completed": { + case "task.progress": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "task.progress", + summary: "Reasoning update", + payload: { + taskId: event.payload.taskId, + detail: truncateDetail(event.payload.description), + ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.completed": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: event.payload.status === "failed" ? "error" : "info", + kind: "task.completed", + summary: + event.payload.status === "failed" + ? "Task failed" + : event.payload.status === "stopped" + ? "Task stopped" + : "Task completed", + payload: { + taskId: event.payload.taskId, + status: event.payload.status, + ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "item.updated": { + if (!isToolLifecycleItemType(event.payload.itemType)) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "tool", + kind: "tool.updated", + summary: event.payload.title ?? "Tool updated", + payload: { + itemType: event.payload.itemType, + ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "item.completed": { + if (!isToolLifecycleItemType(event.payload.itemType)) { + return []; + } return [ { id: event.eventId, createdAt: event.createdAt, tone: "tool", kind: "tool.completed", - summary: `${event.title} complete`, + summary: `${event.payload.title ?? "Tool"} complete`, payload: { - toolKind: event.toolKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + itemType: event.payload.itemType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, }, ]; } - case "tool.started": { + case "item.started": { + if (!isToolLifecycleItemType(event.payload.itemType)) { + return []; + } return [ { id: event.eventId, createdAt: event.createdAt, tone: "tool", kind: "tool.started", - summary: `${event.title} started`, + summary: `${event.payload.title ?? "Tool"} started`, payload: { - toolKind: event.toolKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + itemType: event.payload.itemType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, }, ]; } default: - return []; + break; } + + return []; } const make = Effect.gen(function* () { @@ -187,16 +501,22 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(""), }); + const bufferedProposedPlanById = yield* Cache.make({ + capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, + lookup: () => Effect.succeed({ text: "", createdAt: "" }), + }); + const rememberAssistantMessageId = ( - sessionId: ProviderSessionId, + threadId: ThreadId, turnId: TurnId, messageId: MessageId, ) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Cache.set( turnMessageIdsByTurnKey, - providerTurnKey(sessionId, turnId), + providerTurnKey(threadId, turnId), Option.match(existingIds, { onNone: () => new Set([messageId]), onSome: (ids) => { @@ -210,11 +530,11 @@ const make = Effect.gen(function* () { ); const forgetAssistantMessageId = ( - sessionId: ProviderSessionId, + threadId: ThreadId, turnId: TurnId, messageId: MessageId, ) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Option.match(existingIds, { onNone: () => Effect.void, @@ -222,23 +542,23 @@ const make = Effect.gen(function* () { const nextIds = new Set(ids); nextIds.delete(messageId); if (nextIds.size === 0) { - return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); + return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); } - return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds); + return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds); }, }), ), ); - const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + const getAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.map((existingIds) => Option.getOrElse(existingIds, (): Set => new Set()), ), ); - const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => - Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); + const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( @@ -272,8 +592,30 @@ const make = Effect.gen(function* () { const clearBufferedAssistantText = (messageId: MessageId) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId); - const clearAssistantMessageState = (messageId: MessageId) => - clearBufferedAssistantText(messageId); + const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => + Cache.getOption(bufferedProposedPlanById, planId).pipe( + Effect.flatMap((existingEntry) => { + const existing = Option.getOrUndefined(existingEntry); + return Cache.set(bufferedProposedPlanById, planId, { + text: `${existing?.text ?? ""}${delta}`, + createdAt: existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt, + }); + }), + ); + + const takeBufferedProposedPlan = (planId: string) => + Cache.getOption(bufferedProposedPlanById, planId).pipe( + Effect.flatMap((existingEntry) => + Cache.invalidate(bufferedProposedPlanById, planId).pipe( + Effect.as(Option.getOrUndefined(existingEntry)), + ), + ), + ); + + const clearBufferedProposedPlan = (planId: string) => + Cache.invalidate(bufferedProposedPlanById, planId); + + const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; @@ -283,9 +625,16 @@ const make = Effect.gen(function* () { createdAt: string; commandTag: string; finalDeltaCommandTag: string; + fallbackText?: string; }) => Effect.gen(function* () { - const text = yield* takeBufferedAssistantText(input.messageId); + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const text = + bufferedText.length > 0 + ? bufferedText + : (input.fallbackText?.trim().length ?? 0) > 0 + ? input.fallbackText! + : ""; if (text.length > 0) { yield* orchestrationEngine.dispatch({ @@ -310,10 +659,84 @@ const make = Effect.gen(function* () { yield* clearAssistantMessageState(input.messageId); }); - const clearTurnStateForSession = (sessionId: ProviderSessionId) => + const upsertProposedPlan = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + threadProposedPlans: ReadonlyArray<{ + id: string; + createdAt: string; + }>; + planId: string; + turnId?: TurnId; + planMarkdown: string | undefined; + createdAt: string; + updatedAt: string; + }) => + Effect.gen(function* () { + const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); + if (!planMarkdown) { + return; + } + + const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, + planMarkdown, + createdAt: existingPlan?.createdAt ?? input.createdAt, + updatedAt: input.updatedAt, + }, + createdAt: input.updatedAt, + }); + }); + + const finalizeBufferedProposedPlan = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + threadProposedPlans: ReadonlyArray<{ + id: string; + createdAt: string; + }>; + planId: string; + turnId?: TurnId; + fallbackMarkdown?: string; + updatedAt: string; + }) => Effect.gen(function* () { - const prefix = `${sessionId}:`; + const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); + const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); + const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); + const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; + if (!planMarkdown) { + return; + } + + yield* upsertProposedPlan({ + event: input.event, + threadId: input.threadId, + threadProposedPlans: input.threadProposedPlans, + planId: input.planId, + ...(input.turnId ? { turnId: input.turnId } : {}), + planMarkdown, + createdAt: + bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 + ? bufferedPlan.createdAt + : input.updatedAt, + updatedAt: input.updatedAt, + }); + yield* clearBufferedProposedPlan(input.planId); + }); + + const clearTurnStateForSession = (threadId: ThreadId) => + Effect.gen(function* () { + const prefix = `${threadId}:`; + const proposedPlanPrefix = `plan:${threadId}:`; const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); + const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); yield* Effect.forEach( turnKeys, (key) => @@ -333,26 +756,26 @@ const make = Effect.gen(function* () { }), { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + proposedPlanKeys, + (key) => + key.startsWith(proposedPlanPrefix) + ? Cache.invalidate(bufferedProposedPlanById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); }); const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === event.sessionId, - ); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) return; const now = event.createdAt; - const sessionProviderThreadId = thread.session?.providerThreadId ?? null; - const eventProviderThreadId = toProviderThreadId(event.threadId); - const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); + const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const matchesThreadScope = - eventProviderThreadId === null || - sessionProviderThreadId === null || - sameId(eventProviderThreadId, sessionProviderThreadId); const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; @@ -366,27 +789,10 @@ const make = Effect.gen(function* () { return true; case "session.started": case "thread.started": - if (!matchesThreadScope) { - return false; - } - // Never let auxiliary/provider-side spawned threads replace the primary thread binding. - if ( - eventProviderThreadId !== null && - sessionProviderThreadId !== null && - !sameId(eventProviderThreadId, sessionProviderThreadId) - ) { - return false; - } return true; case "turn.started": - if (!matchesThreadScope) { - return false; - } return !conflictsWithActiveTurn; case "turn.completed": - if (!matchesThreadScope) { - return false; - } if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -394,8 +800,8 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // Without an active turn, only accept completion when no thread mismatch signal exists. - return eventProviderThreadId === null || sessionProviderThreadId === null; + // If no active turn is tracked, accept completion scoped to this thread. + return true; default: return true; } @@ -403,31 +809,41 @@ const make = Effect.gen(function* () { if ( event.type === "session.started" || + event.type === "session.state.changed" || event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || event.type === "turn.completed" ) { - const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) : null; - const providerThreadIdFromEvent = - event.type === "thread.started" - ? ProviderThreadId.makeUnsafe(event.threadId) - : event.threadId !== undefined - ? ProviderThreadId.makeUnsafe(event.threadId) - : null; - const providerThreadId = providerThreadIdFromEvent ?? sessionProviderThreadId ?? null; - const status = + const nextActiveTurnId = event.type === "turn.started" - ? "running" - : event.type === "session.exited" - ? "stopped" - : event.type === "turn.completed" && event.status === "failed" - ? "error" - : "ready"; + ? (eventTurnId ?? null) + : 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 lastError = - event.type === "turn.completed" && event.status === "failed" - ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + event.type === "session.state.changed" && event.payload.state === "error" + ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") + : event.type === "turn.completed" && runtimeTurnState(event) === "failed" + ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" ? null : (thread.session?.lastError ?? null); @@ -440,10 +856,7 @@ const make = Effect.gen(function* () { threadId: thread.id, status, providerName: event.provider, - providerSessionId: event.sessionId, - providerThreadId, - approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, - sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, + runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, updatedAt: now, @@ -453,18 +866,25 @@ const make = Effect.gen(function* () { } } - if (event.type === "message.delta" && event.delta.length > 0) { + const assistantDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? event.payload.delta + : undefined; + const proposedPlanDelta = + event.type === "turn.proposed.delta" ? event.payload.delta : undefined; + + if (assistantDelta && assistantDelta.length > 0) { const assistantMessageId = MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); const turnId = toTurnId(event.turnId); if (turnId) { - yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta); + const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", @@ -482,18 +902,39 @@ const make = Effect.gen(function* () { commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - delta: event.delta, + delta: assistantDelta, ...(turnId ? { turnId } : {}), createdAt: now, }); } } - if (event.type === "message.completed") { - const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); + if (proposedPlanDelta && proposedPlanDelta.length > 0) { + const planId = proposedPlanIdFromEvent(event, thread.id); + yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); + } + + const assistantCompletion = + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? { + messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), + fallbackText: event.payload.detail, + } + : undefined; + const proposedPlanCompletion = + event.type === "turn.proposed.completed" + ? { + planId: proposedPlanIdFromEvent(event, thread.id), + turnId: toTurnId(event.turnId), + planMarkdown: event.payload.planMarkdown, + } + : undefined; + + if (assistantCompletion) { + const assistantMessageId = assistantCompletion.messageId; const turnId = toTurnId(event.turnId); if (turnId) { - yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } yield* finalizeAssistantMessage({ @@ -504,17 +945,32 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete", finalDeltaCommandTag: "assistant-delta-finalize", + ...(assistantCompletion.fallbackText !== undefined + ? { fallbackText: assistantCompletion.fallbackText } + : {}), }); if (turnId) { - yield* forgetAssistantMessageId(event.sessionId, turnId, assistantMessageId); + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); } } + if (proposedPlanCompletion) { + yield* finalizeBufferedProposedPlan({ + event, + threadId: thread.id, + threadProposedPlans: thread.proposedPlans, + planId: proposedPlanCompletion.planId, + ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), + fallbackMarkdown: proposedPlanCompletion.planMarkdown, + updatedAt: now, + }); + } + if (event.type === "turn.completed") { const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(event.sessionId, turnId); + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => @@ -529,26 +985,31 @@ const make = Effect.gen(function* () { }), { concurrency: 1 }, ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(event.sessionId, turnId); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + + yield* finalizeBufferedProposedPlan({ + event, + threadId: thread.id, + threadProposedPlans: thread.proposedPlans, + planId: proposedPlanIdForTurn(thread.id, turnId), + turnId, + updatedAt: now, + }); } } if (event.type === "session.exited") { - yield* clearTurnStateForSession(event.sessionId); + yield* clearTurnStateForSession(thread.id); } if (event.type === "runtime.error") { + const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; + const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true - : matchesThreadScope && - (activeTurnId === null || - eventTurnId === undefined || - sameId(activeTurnId, eventTurnId)); - - const providerThreadId = - event.threadId !== undefined - ? ProviderThreadId.makeUnsafe(event.threadId) - : (thread.session?.providerThreadId ?? null); + : activeTurnId === null || + eventTurnId === undefined || + sameId(activeTurnId, eventTurnId); if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ @@ -559,12 +1020,9 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "error", providerName: event.provider, - providerSessionId: event.sessionId, - providerThreadId, - approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, - sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, + runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, - lastError: event.message, + lastError: runtimeErrorMessage, updatedAt: now, }, createdAt: now, @@ -572,6 +1030,37 @@ const make = Effect.gen(function* () { } } + if (event.type === "thread.metadata.updated" && event.payload.name) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } + + if (event.type === "turn.diff.updated") { + const turnId = toTurnId(event.turnId); + if (turnId) { + const assistantMessageId = MessageId.makeUnsafe( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: providerCommandId(event, "thread-turn-diff-complete"), + threadId: thread.id, + turnId, + completedAt: now, + checkpointRef: CheckpointRef.makeUnsafe(`provider-diff:${event.eventId}`), + status: "missing", + files: [], + assistantMessageId, + checkpointTurnCount: thread.checkpoints.length + 1, + createdAt: now, + }); + } + } + const activities = runtimeEventToActivities(event); yield* Effect.forEach(activities, (activity) => orchestrationEngine.dispatch({ diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 03a5e35057..c96385cad1 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,8 +4,11 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, + ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, + ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, + ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, @@ -24,9 +27,12 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; +export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; +export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; +export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index af64eba008..f95e4db754 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { MessageId, CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, ProjectId, ThreadId, type OrchestrationCommand, @@ -50,6 +51,8 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -58,6 +61,7 @@ const readModel: OrchestrationReadModel = { messages: [], session: null, activities: [], + proposedPlans: [], checkpoints: [], deletedAt: null, }, @@ -66,6 +70,8 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -74,6 +80,7 @@ const readModel: OrchestrationReadModel = { messages: [], session: null, activities: [], + proposedPlans: [], checkpoints: [], deletedAt: null, }, @@ -90,8 +97,8 @@ const messageSendCommand: OrchestrationCommand = { text: "hello", attachments: [], }, - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }; @@ -138,6 +145,8 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "new", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -157,6 +166,8 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "dup", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 6d528614de..516d8b2a28 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -1,4 +1,11 @@ -import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; @@ -130,6 +137,8 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt: now, @@ -150,10 +159,16 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - model: "gpt-5", - effort: "high", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }, readModel, @@ -174,10 +189,173 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), messageId: asMessageId("message-user-1"), - model: "gpt-5", - effort: "high", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + runtimeMode: "approval-required", + }); + }); + + it("emits thread.runtime-mode-set from thread.runtime-mode.set", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }, + readModel, + }), + ); + + const singleResult = Array.isArray(result) ? null : result; + if (singleResult === null) { + throw new Error("Expected a single runtime-mode-set event."); + } + expect(singleResult).toMatchObject({ + type: "thread.runtime-mode-set", + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + }, + }); + }); + + it("emits thread.interaction-mode-set from thread.interaction-mode.set", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.interaction-mode.set", + commandId: CommandId.makeUnsafe("cmd-interaction-mode-set"), + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + createdAt: now, + }, + readModel, + }), + ); + + const singleResult = Array.isArray(result) ? null : result; + if (singleResult === null) { + throw new Error("Expected a single interaction-mode-set event."); + } + expect(singleResult).toMatchObject({ + type: "thread.interaction-mode-set", + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + interactionMode: "plan", + }, }); }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 45268a1132..7fe8eb746d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -157,6 +157,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, model: command.model, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, createdAt: command.createdAt, @@ -213,6 +215,52 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.runtime-mode.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.runtime-mode-set", + payload: { + threadId: command.threadId, + runtimeMode: command.runtimeMode, + updatedAt: occurredAt, + }, + }; + } + + case "thread.interaction-mode.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.interaction-mode-set", + payload: { + threadId: command.threadId, + interactionMode: command.interactionMode, + updatedAt: occurredAt, + }, + }; + } + case "thread.turn.start": { yield* requireThread({ readModel, @@ -251,11 +299,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, + ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), - ...(command.effort !== undefined ? { effort: command.effort } : {}), + ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - approvalPolicy: command.approvalPolicy, - sandboxMode: command.sandboxMode, + runtimeMode: + readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? + command.runtimeMode, + interactionMode: + readModel.threads.find((entry) => entry.id === command.threadId)?.interactionMode ?? + command.interactionMode, createdAt: command.createdAt, }, }; @@ -310,6 +363,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.user-input.respond": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { + requestId: command.requestId, + }, + }), + type: "thread.user-input-response-requested", + payload: { + threadId: command.threadId, + requestId: command.requestId, + answers: command.answers, + createdAt: command.createdAt, + }, + }; + } + case "thread.checkpoint.revert": { yield* requireThread({ readModel, @@ -365,14 +444,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - metadata: { - ...(command.session.providerSessionId !== null - ? { providerSessionId: command.session.providerSessionId } - : {}), - ...(command.session.providerThreadId !== null - ? { providerThreadId: command.session.providerThreadId } - : {}), - }, + metadata: {}, }), type: "thread.session-set", payload: { @@ -436,6 +508,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.proposed-plan.upsert": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: command.proposedPlan, + }, + }; + } + case "thread.turn.diff.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 094290d90f..71f5b6bd4b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -57,6 +57,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -73,6 +74,8 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", branch: null, worktreePath: null, latestTurn: null, @@ -80,6 +83,7 @@ describe("orchestration projector", () => { updatedAt: now, deletedAt: null, messages: [], + proposedPlans: [], activities: [], checkpoints: [], session: null, @@ -135,8 +139,7 @@ describe("orchestration projector", () => { payload: { threadId: "thread-1", messageId: "message-1", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", createdAt: "2026-01-01T00:00:00.000Z", }, }), @@ -168,6 +171,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -195,8 +199,7 @@ describe("orchestration projector", () => { providerName: "codex", providerSessionId: "session-1", providerThreadId: "provider-thread-1", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", activeTurnId: "turn-1", lastError: null, updatedAt: startedAt, @@ -211,6 +214,59 @@ describe("orchestration projector", () => { expect(thread?.session?.status).toBe("running"); }); + it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const updatedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterUpdate = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.runtime-mode-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: updatedAt, + commandId: "cmd-runtime-mode-set", + payload: { + threadId: "thread-1", + runtimeMode: "approval-required", + updatedAt, + }, + }), + ), + ); + + expect(afterUpdate.threads[0]?.runtimeMode).toBe("approval-required"); + expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); + }); + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; @@ -232,6 +288,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -315,6 +372,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -526,6 +584,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -675,6 +734,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "capped", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f92f7172a4..c0badfe958 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -16,7 +16,10 @@ import { ThreadActivityAppendedPayload, ThreadCreatedPayload, ThreadDeletedPayload, + ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, + ThreadProposedPlanUpsertedPayload, + ThreadRuntimeModeSetPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -124,6 +127,32 @@ function retainThreadActivitiesAfterRevert( ); } +function retainThreadProposedPlansAfterRevert( + proposedPlans: ReadonlyArray, + retainedTurnIds: ReadonlySet, +): ReadonlyArray { + return proposedPlans.filter( + (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), + ); +} + +function compareThreadActivities( + left: OrchestrationThread["activities"][number], + right: OrchestrationThread["activities"][number], +): number { + if (left.sequence !== undefined && right.sequence !== undefined) { + if (left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + } else if (left.sequence !== undefined) { + return 1; + } else if (right.sequence !== undefined) { + return -1; + } + + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { return { snapshotSequence: 0, @@ -224,6 +253,8 @@ export function projectEvent( projectId: payload.projectId, title: payload.title, model: payload.model, + runtimeMode: payload.runtimeMode, + interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, latestTurn: null, @@ -272,6 +303,38 @@ export function projectEvent( })), ); + case "thread.runtime-mode-set": + return decodeForEvent( + ThreadRuntimeModeSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + runtimeMode: payload.runtimeMode, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.interaction-mode-set": + return decodeForEvent( + ThreadInteractionModeSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + interactionMode: payload.interactionMode, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( @@ -382,6 +445,38 @@ export function projectEvent( }; }); + case "thread.proposed-plan-upserted": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadProposedPlanUpsertedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + const proposedPlans = [ + ...thread.proposedPlans.filter((entry) => entry.id !== payload.proposedPlan.id), + payload.proposedPlan, + ] + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .slice(-200); + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + proposedPlans, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( @@ -458,6 +553,10 @@ export function projectEvent( retainedTurnIds, payload.turnCount, ).slice(-MAX_THREAD_MESSAGES); + const proposedPlans = retainThreadProposedPlansAfterRevert( + thread.proposedPlans, + retainedTurnIds, + ).slice(-200); const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); const latestCheckpoint = checkpoints.at(-1) ?? null; @@ -478,6 +577,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, messages, + proposedPlans, activities, latestTurn, updatedAt: event.occurredAt, @@ -503,7 +603,7 @@ export function projectEvent( ...thread.activities.filter((entry) => entry.id !== payload.activity.id), payload.activity, ] - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) + .toSorted(compareThreadActivities) .slice(-500); return { diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 6f166417b5..4d81cf5e8d 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -74,8 +74,6 @@ function inferActorKind( return "server"; } if ( - event.metadata.providerSessionId !== undefined || - event.metadata.providerThreadId !== undefined || event.metadata.providerTurnId !== undefined || event.metadata.providerItemId !== undefined || event.metadata.adapterKey !== undefined diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index c2285f95de..8e88cfa785 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -1,5 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { NonNegativeInt } from "@t3tools/contracts"; import { Effect, Layer, Schema, Struct } from "effect"; import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; @@ -15,6 +16,7 @@ import { const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), }), ); @@ -29,7 +31,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const upsertProjectionThreadActivityRow = SqlSchema.void({ - Request: ProjectionThreadActivityDbRowSchema, + Request: ProjectionThreadActivity, execute: (row) => sql` INSERT INTO projection_thread_activities ( @@ -40,6 +42,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { kind, summary, payload_json, + sequence, created_at ) VALUES ( @@ -49,7 +52,8 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { ${row.tone}, ${row.kind}, ${row.summary}, - ${row.payload}, + ${JSON.stringify(row.payload)}, + ${row.sequence ?? null}, ${row.createdAt} ) ON CONFLICT (activity_id) @@ -60,6 +64,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { kind = excluded.kind, summary = excluded.summary, payload_json = excluded.payload_json, + sequence = excluded.sequence, created_at = excluded.created_at `, }); @@ -77,10 +82,15 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { kind, summary, payload_json AS "payload", + sequence, created_at AS "createdAt" FROM projection_thread_activities WHERE thread_id = ${threadId} - ORDER BY created_at ASC, activity_id ASC + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC `, }); @@ -111,8 +121,18 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map( - (rows) => rows as ReadonlyArray>, + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), ), ); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts new file mode 100644 index 0000000000..24446e04dc --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -0,0 +1,104 @@ +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadProposedPlansInput, + ListProjectionThreadProposedPlansInput, + ProjectionThreadProposedPlan, + ProjectionThreadProposedPlanRepository, + type ProjectionThreadProposedPlanRepositoryShape, +} from "../Services/ProjectionThreadProposedPlans.ts"; + +const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ + Request: ProjectionThreadProposedPlan, + execute: (row) => sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + created_at, + updated_at + ) + VALUES ( + ${row.planId}, + ${row.threadId}, + ${row.turnId}, + ${row.planMarkdown}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (plan_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + plan_markdown = excluded.plan_markdown, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ + Request: ListProjectionThreadProposedPlansInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlansInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => + upsertProjectionThreadProposedPlanRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"), + ), + ); + + const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadProposedPlanRepositoryShape["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadProposedPlanRepositoryShape; +}); + +export const ProjectionThreadProposedPlanRepositoryLive = Layer.effect( + ProjectionThreadProposedPlanRepository, + makeProjectionThreadProposedPlanRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 0793ef388d..2499eba196 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -23,10 +23,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id, status, provider_name, - provider_session_id, - provider_thread_id, - approval_policy, - sandbox_mode, + runtime_mode, active_turn_id, last_error, updated_at @@ -35,10 +32,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.threadId}, ${row.status}, ${row.providerName}, - ${row.providerSessionId}, - ${row.providerThreadId}, - ${row.approvalPolicy}, - ${row.sandboxMode}, + ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, ${row.updatedAt} @@ -47,10 +41,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { DO UPDATE SET status = excluded.status, provider_name = excluded.provider_name, - provider_session_id = excluded.provider_session_id, - provider_thread_id = excluded.provider_thread_id, - approval_policy = excluded.approval_policy, - sandbox_mode = excluded.sandbox_mode, + runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, updated_at = excluded.updated_at @@ -66,10 +57,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", - provider_session_id AS "providerSessionId", - provider_thread_id AS "providerThreadId", - approval_policy AS "approvalPolicy", - sandbox_mode AS "sandboxMode", + runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 810deb3827..10192697d0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -24,6 +24,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id, title, model, + runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, @@ -36,6 +38,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.model}, + ${row.runtimeMode}, + ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, ${row.latestTurnId}, @@ -48,6 +52,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id = excluded.project_id, title = excluded.title, model = excluded.model, + runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, latest_turn_id = excluded.latest_turn_id, @@ -67,6 +73,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -88,6 +96,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 85c9bc1caa..da3e8bce90 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,4 +1,4 @@ -import { ProviderSessionId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema, Struct } from "effect"; @@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); const GetRuntimeRequestSchema = Schema.Struct({ - providerSessionId: ProviderSessionId, + threadId: ThreadId, }); const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; @@ -44,33 +44,30 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { execute: (runtime) => sql` INSERT INTO provider_session_runtime ( - provider_session_id, thread_id, provider_name, adapter_key, - provider_thread_id, + runtime_mode, status, last_seen_at, resume_cursor_json, runtime_payload_json ) VALUES ( - ${runtime.providerSessionId}, ${runtime.threadId}, ${runtime.providerName}, ${runtime.adapterKey}, - ${runtime.providerThreadId}, + ${runtime.runtimeMode}, ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, ${runtime.runtimePayload} ) - ON CONFLICT (provider_session_id) + ON CONFLICT (thread_id) DO UPDATE SET - thread_id = excluded.thread_id, provider_name = excluded.provider_name, adapter_key = excluded.adapter_key, - provider_thread_id = excluded.provider_thread_id, + runtime_mode = excluded.runtime_mode, status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, @@ -78,23 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { `, }); - const getRuntimeRowBySessionId = SqlSchema.findOneOption({ + const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, Result: ProviderSessionRuntimeDbRowSchema, - execute: ({ providerSessionId }) => + execute: ({ threadId }) => sql` SELECT - provider_session_id AS "providerSessionId", thread_id AS "threadId", provider_name AS "providerName", adapter_key AS "adapterKey", - provider_thread_id AS "providerThreadId", + runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime - WHERE provider_session_id = ${providerSessionId} + WHERE thread_id = ${threadId} `, }); @@ -104,26 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { execute: () => sql` SELECT - provider_session_id AS "providerSessionId", thread_id AS "threadId", provider_name AS "providerName", adapter_key AS "adapterKey", - provider_thread_id AS "providerThreadId", + runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime - ORDER BY last_seen_at ASC, provider_session_id ASC + ORDER BY last_seen_at ASC, thread_id ASC `, }); - const deleteRuntimeBySessionId = SqlSchema.void({ + const deleteRuntimeByThreadId = SqlSchema.void({ Request: DeleteRuntimeRequestSchema, - execute: ({ providerSessionId }) => + execute: ({ threadId }) => sql` DELETE FROM provider_session_runtime - WHERE provider_session_id = ${providerSessionId} + WHERE thread_id = ${threadId} `, }); @@ -137,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ); - const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) => - getRuntimeRowBySessionId(input).pipe( + const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => + getRuntimeRowByThreadId(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.getBySessionId:query", - "ProviderSessionRuntimeRepository.getBySessionId:decodeRow", + "ProviderSessionRuntimeRepository.getByThreadId:query", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", ), ), Effect.flatMap((runtimeRowOption) => @@ -152,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { decodeRuntime(row).pipe( Effect.mapError( toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime", + "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", ), ), Effect.map((runtime) => Option.some(runtime)), @@ -183,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ); - const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) => - deleteRuntimeBySessionId(input).pipe( + const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => + deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"), + toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), ), ); return { upsert, - getBySessionId, + getByThreadId, list, - deleteBySessionId, + deleteByThreadId, } satisfies ProviderSessionRuntimeRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts deleted file mode 100644 index eb868fa20c..0000000000 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; - -import { - ProviderSessionRepositoryPersistenceError, - ProviderSessionRepositoryValidationError, -} from "../Errors.ts"; -import { - ProviderSessionRepository, - type ProviderSessionEntry, - type ProviderSessionRepositoryShape, -} from "../Services/ProviderSessions.ts"; - -const ProviderKind = Schema.Literals(["codex", "claudeCode"]); - -const ProviderSessionRowSchema = Schema.Struct({ - sessionId: ProviderSessionId, - provider: ProviderKind, - threadId: Schema.NullOr(ThreadId), - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const SessionIdRequestSchema = Schema.Struct({ - sessionId: ProviderSessionId, -}); - -const UpsertSessionRequestSchema = Schema.Struct({ - sessionId: ProviderSessionId, - provider: ProviderKind, - threadId: Schema.NullOr(ThreadId), -}); - -function errorMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toValidationError( - operation: string, - cause: unknown, -): ProviderSessionRepositoryValidationError { - return new ProviderSessionRepositoryValidationError({ - operation, - issue: errorMessage(cause, "Invalid provider session repository input."), - cause, - }); -} - -function decodeInput(schema: S, input: unknown, operation: string) { - return Schema.decodeUnknownEffect(schema)(input).pipe( - Effect.mapError((cause) => toValidationError(operation, cause)), - ); -} - -function toPersistenceError( - operation: string, - cause: unknown, -): ProviderSessionRepositoryPersistenceError { - return new ProviderSessionRepositoryPersistenceError({ - operation, - detail: `Failed to execute ${operation}.`, - cause, - }); -} - -function toEntry(row: Schema.Schema.Type): ProviderSessionEntry { - return { - sessionId: row.sessionId, - provider: row.provider, - ...(row.threadId !== null ? { threadId: row.threadId } : {}), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -const makeProviderSessionRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertSessionRow = SqlSchema.findOne({ - Request: UpsertSessionRequestSchema, - Result: ProviderSessionRowSchema, - execute: (request) => - sql` - INSERT INTO provider_sessions ( - session_id, - provider, - thread_id, - created_at, - updated_at - ) - VALUES ( - ${request.sessionId}, - ${request.provider}, - ${request.threadId}, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - ) - ON CONFLICT (session_id) - DO UPDATE SET - provider = excluded.provider, - thread_id = excluded.thread_id, - updated_at = CURRENT_TIMESTAMP - RETURNING - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - `, - }); - - const findSessionRow = SqlSchema.findOneOption({ - Request: SessionIdRequestSchema, - Result: ProviderSessionRowSchema, - execute: ({ sessionId }) => - sql` - SELECT - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM provider_sessions - WHERE session_id = ${sessionId} - `, - }); - - const listSessionRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProviderSessionRowSchema, - execute: () => - sql` - SELECT - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM provider_sessions - ORDER BY created_at ASC, session_id ASC - `, - }); - - const deleteSessionRow = SqlSchema.void({ - Request: SessionIdRequestSchema, - execute: ({ sessionId }) => sql`DELETE FROM provider_sessions WHERE session_id = ${sessionId}`, - }); - - const upsertSession: ProviderSessionRepositoryShape["upsertSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - UpsertSessionRequestSchema, - { - sessionId: input.sessionId, - provider: input.provider, - threadId: input.threadId ?? null, - }, - "ProviderSessionRepository.upsertSession", - ); - - yield* upsertSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.upsertSession:query", cause), - ), - Effect.asVoid, - ); - }); - - const getSession: ProviderSessionRepositoryShape["getSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - SessionIdRequestSchema, - { sessionId: input.sessionId }, - "ProviderSessionRepository.getSession", - ); - - const row = yield* findSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.getSession:query", cause), - ), - ); - - return Option.map(row, toEntry); - }); - - const listSessions: ProviderSessionRepositoryShape["listSessions"] = () => - listSessionRows(undefined).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.listSessions:query", cause), - ), - Effect.map((rows) => rows.map(toEntry)), - ); - - const deleteSession: ProviderSessionRepositoryShape["deleteSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - SessionIdRequestSchema, - { sessionId: input.sessionId }, - "ProviderSessionRepository.deleteSession", - ); - - yield* deleteSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.deleteSession:query", cause), - ), - ); - }); - - return { - upsertSession, - getSession, - listSessions, - deleteSession, - } satisfies ProviderSessionRepositoryShape; -}); - -export const ProviderSessionRepositoryLive = Layer.effect( - ProviderSessionRepository, - makeProviderSessionRepository, -); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 3df831d0c7..7deb890dd8 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -19,6 +19,13 @@ import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; import Migration0005 from "./Migrations/005_Projections.ts"; import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; +import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"; +import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; +import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; +import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; +import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; +import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import { Effect } from "effect"; /** * Migration loader with all migrations defined inline. @@ -38,6 +45,12 @@ const loader = Migrator.fromRecord({ "5_Projections": Migration0005, "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, "7_ProjectionThreadMessageAttachments": Migration0007, + "8_ProjectionThreadActivitySequence": Migration0008, + "9_ProviderSessionRuntimeMode": Migration0009, + "10_ProjectionThreadsRuntimeMode": Migration0010, + "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, + "12_ProjectionThreadsInteractionMode": Migration0012, + "13_ProjectionThreadProposedPlans": Migration0013, }); /** @@ -56,7 +69,11 @@ const run = Migrator.make({}); * * @returns Effect containing array of executed migrations */ -export const runMigrations = run({ loader }); +export const runMigrations = Effect.gen(function* () { + yield* Effect.log("Running migrations..."); + yield* run({ loader }); + yield* Effect.log("Migrations ran successfully"); +}); /** * Layer that runs migrations when the layer is built. diff --git a/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts index a6adda0a87..f468ad49cf 100644 --- a/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts @@ -6,11 +6,10 @@ export default Effect.gen(function* () { yield* sql` CREATE TABLE IF NOT EXISTS provider_session_runtime ( - provider_session_id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL, + thread_id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, adapter_key TEXT NOT NULL, - provider_thread_id TEXT, + runtime_mode TEXT NOT NULL DEFAULT 'full-access', status TEXT NOT NULL, last_seen_at TEXT NOT NULL, resume_cursor_json TEXT, @@ -18,11 +17,6 @@ export default Effect.gen(function* () { ) `; - yield* sql` - CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread - ON provider_session_runtime(thread_id) - `; - yield* sql` CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status ON provider_session_runtime(status) diff --git a/apps/server/src/persistence/Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts b/apps/server/src/persistence/Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts index bcdef28016..a988a50976 100644 --- a/apps/server/src/persistence/Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts +++ b/apps/server/src/persistence/Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts @@ -1,30 +1,19 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as Effect from "effect/Effect"; -const DEFAULT_APPROVAL_POLICY = "never"; -const DEFAULT_SANDBOX_MODE = "workspace-write"; +const DEFAULT_RUNTIME_MODE = "full-access"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql` ALTER TABLE projection_thread_sessions - ADD COLUMN approval_policy TEXT NOT NULL DEFAULT 'never' + ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access' `; - yield* sql` - ALTER TABLE projection_thread_sessions - ADD COLUMN sandbox_mode TEXT NOT NULL DEFAULT 'workspace-write' - `; - - yield* sql` - UPDATE projection_thread_sessions - SET approval_policy = ${DEFAULT_APPROVAL_POLICY} - WHERE approval_policy IS NULL - `; yield* sql` UPDATE projection_thread_sessions - SET sandbox_mode = ${DEFAULT_SANDBOX_MODE} - WHERE sandbox_mode IS NULL + SET runtime_mode = ${DEFAULT_RUNTIME_MODE} + WHERE runtime_mode IS NULL `; }); diff --git a/apps/server/src/persistence/Migrations/008_ProjectionThreadActivitySequence.ts b/apps/server/src/persistence/Migrations/008_ProjectionThreadActivitySequence.ts new file mode 100644 index 0000000000..6000f0c62c --- /dev/null +++ b/apps/server/src/persistence/Migrations/008_ProjectionThreadActivitySequence.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_thread_activities + ADD COLUMN sequence INTEGER + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence + ON projection_thread_activities(thread_id, sequence) + `; +}); diff --git a/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts new file mode 100644 index 0000000000..3f7249e873 --- /dev/null +++ b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts @@ -0,0 +1,6 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + yield* SqlClient.SqlClient; +}); diff --git a/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts b/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts new file mode 100644 index 0000000000..93d1f0a8ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access' + `; + + yield* sql` + UPDATE projection_threads + SET runtime_mode = 'full-access' + WHERE runtime_mode IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts b/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts new file mode 100644 index 0000000000..a1021a4da9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.runtimeMode', 'full-access') + WHERE event_type = 'thread.created' + AND json_type(payload_json, '$.runtimeMode') IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts b/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts new file mode 100644 index 0000000000..60695ac6cc --- /dev/null +++ b/apps/server/src/persistence/Migrations/012_ProjectionThreadsInteractionMode.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN interaction_mode TEXT NOT NULL DEFAULT 'default' + `; +}); diff --git a/apps/server/src/persistence/Migrations/013_ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Migrations/013_ProjectionThreadProposedPlans.ts new file mode 100644 index 0000000000..2af642c838 --- /dev/null +++ b/apps/server/src/persistence/Migrations/013_ProjectionThreadProposedPlans.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_proposed_plans ( + plan_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + plan_markdown TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_proposed_plans_thread_created + ON projection_thread_proposed_plans(thread_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index e9e39b49d0..586ae3eb4a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -9,6 +9,7 @@ import { EventId, IsoDateTime, + NonNegativeInt, OrchestrationThreadActivityTone, ThreadId, TurnId, @@ -26,6 +27,7 @@ export const ProjectionThreadActivity = Schema.Struct({ kind: Schema.String, summary: Schema.String, payload: Schema.Unknown, + sequence: Schema.optional(NonNegativeInt), createdAt: IsoDateTime, }); export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; @@ -57,7 +59,8 @@ export interface ProjectionThreadActivityRepositoryShape { /** * List projected thread activity rows for a thread. * - * Returned in ascending creation order. + * Returned in ascending runtime sequence order (or creation order when + * sequence is unavailable). */ readonly listByThreadId: ( input: ListProjectionThreadActivitiesInput, diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts new file mode 100644 index 0000000000..ee662d52be --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -0,0 +1,52 @@ +import { + IsoDateTime, + OrchestrationProposedPlanId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "@t3tools/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadProposedPlan = Schema.Struct({ + planId: OrchestrationProposedPlanId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + planMarkdown: TrimmedNonEmptyString, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; + +export const ListProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadProposedPlansInput = + typeof ListProjectionThreadProposedPlansInput.Type; + +export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadProposedPlansInput = + typeof DeleteProjectionThreadProposedPlansInput.Type; + +export interface ProjectionThreadProposedPlanRepositoryShape { + readonly upsert: ( + proposedPlan: ProjectionThreadProposedPlan, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadProposedPlansInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadProposedPlansInput, + ) => Effect.Effect; +} + +export class ProjectionThreadProposedPlanRepository extends ServiceMap.Service< + ProjectionThreadProposedPlanRepository, + ProjectionThreadProposedPlanRepositoryShape +>()( + "t3/persistence/Services/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository", +) {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 2789e2481e..537ee10bee 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -7,12 +7,9 @@ * @module ProjectionThreadSessionRepository */ import { + RuntimeMode, IsoDateTime, OrchestrationSessionStatus, - ProviderApprovalPolicy, - ProviderSandboxMode, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -25,10 +22,7 @@ export const ProjectionThreadSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), - providerSessionId: Schema.NullOr(ProviderSessionId), - providerThreadId: Schema.NullOr(ProviderThreadId), - approvalPolicy: ProviderApprovalPolicy, - sandboxMode: ProviderSandboxMode, + runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), updatedAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 512c72586a..7a30870f2d 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -6,7 +6,14 @@ * * @module ProjectionThreadRepository */ -import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + IsoDateTime, + ProjectId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -17,6 +24,8 @@ export const ProjectionThread = Schema.Struct({ projectId: ProjectId, title: Schema.String, model: Schema.String, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index e0727a8acd..885a9dd5f1 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -7,9 +7,8 @@ */ import { IsoDateTime, - ProviderSessionId, ProviderSessionRuntimeStatus, - ProviderThreadId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; @@ -18,11 +17,10 @@ import type { Effect } from "effect"; import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; export const ProviderSessionRuntime = Schema.Struct({ - providerSessionId: ProviderSessionId, threadId: ThreadId, providerName: Schema.String, adapterKey: Schema.String, - providerThreadId: Schema.NullOr(ProviderThreadId), + runtimeMode: RuntimeMode, status: ProviderSessionRuntimeStatus, lastSeenAt: IsoDateTime, resumeCursor: Schema.NullOr(Schema.Unknown), @@ -30,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({ }); export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; -export const GetProviderSessionRuntimeInput = Schema.Struct({ - providerSessionId: ProviderSessionId, -}); +export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; -export const DeleteProviderSessionRuntimeInput = Schema.Struct({ - providerSessionId: ProviderSessionId, -}); +export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; /** @@ -47,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape { /** * Insert or replace a provider runtime row. * - * Upserts by `providerSessionId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, including JSON payload/cursor fields. */ readonly upsert: ( runtime: ProviderSessionRuntime, ) => Effect.Effect; /** - * Read provider runtime state by provider session id. + * Read provider runtime state by canonical thread id. */ - readonly getBySessionId: ( + readonly getByThreadId: ( input: GetProviderSessionRuntimeInput, ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; @@ -71,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape { >; /** - * Delete provider runtime state by provider session id. + * Delete provider runtime state by canonical thread id. */ - readonly deleteBySessionId: ( + readonly deleteByThreadId: ( input: DeleteProviderSessionRuntimeInput, ) => Effect.Effect; } diff --git a/apps/server/src/persistence/Services/ProviderSessions.ts b/apps/server/src/persistence/Services/ProviderSessions.ts deleted file mode 100644 index 33336a53ac..0000000000 --- a/apps/server/src/persistence/Services/ProviderSessions.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * ProviderSessionRepository - Repository interface for provider session lookup. - * - * Owns persistence operations that map internal sessions to provider kinds and - * optional thread ownership. - * - * @module ProviderSessionRepository - */ -import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import { Option, ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { ProviderSessionRepositoryError } from "../Errors.ts"; - -export interface ProviderSessionEntry { - readonly sessionId: ProviderSessionId; - readonly provider: ProviderKind; - readonly threadId?: ThreadId; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface UpsertProviderSessionInput { - readonly sessionId: ProviderSessionId; - readonly provider: ProviderKind; - readonly threadId?: ThreadId; -} - -export interface DeleteProviderSessionInput { - readonly sessionId: ProviderSessionId; -} - -export interface GetProviderSessionInput { - readonly sessionId: ProviderSessionId; -} - -/** - * ProviderSessionRepositoryShape - Service API for provider-session records. - */ -export interface ProviderSessionRepositoryShape { - /** - * Insert or replace a provider-session row. - * - * Upserts by `sessionId`. - */ - readonly upsertSession: ( - input: UpsertProviderSessionInput, - ) => Effect.Effect; - - /** - * Read a provider-session row by session id. - */ - readonly getSession: ( - input: GetProviderSessionInput, - ) => Effect.Effect, ProviderSessionRepositoryError>; - - /** - * List all provider-session rows. - * - * Returned in deterministic creation order. - */ - readonly listSessions: () => Effect.Effect< - ReadonlyArray, - ProviderSessionRepositoryError - >; - - /** - * Delete a provider-session row by session id. - */ - readonly deleteSession: ( - input: DeleteProviderSessionInput, - ) => Effect.Effect; -} - -/** - * ProviderSessionRepository - Service tag for provider-session persistence. - */ -export class ProviderSessionRepository extends ServiceMap.Service< - ProviderSessionRepository, - ProviderSessionRepositoryShape ->()("t3/persistence/Services/ProviderSessions/ProviderSessionRepository") {} diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index a3d29f77cc..e4e46d3748 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -26,12 +26,12 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass "ProviderAdapterSessionNotFoundError", { provider: Schema.String, - sessionId: Schema.String, + threadId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown ${this.provider} adapter session: ${this.sessionId}`; + return `Unknown ${this.provider} adapter thread: ${this.threadId}`; } } @@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass

()( "ProviderSessionNotFoundError", { - sessionId: Schema.String, + threadId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown provider session: ${this.sessionId}`; + return `Unknown provider thread: ${this.threadId}`; } } 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..e6bccec4ed --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,1857 @@ +/** + * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter. + * + * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic + * provider adapter contract and emits canonical runtime events. + * + * @module ClaudeCodeAdapterLive + */ +import { + type CanUseTool, + query, + type Options as ClaudeQueryOptions, + type PermissionMode, + type PermissionResult, + type PermissionUpdate, + type SDKMessage, + type SDKResultMessage, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + EventId, + type ProviderApprovalDecision, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "claudeCode" as const; + +type PromptQueueItem = + | { + readonly type: "message"; + readonly message: SDKUserMessage; + } + | { + readonly type: "terminate"; + }; + +interface ClaudeResumeState { + readonly threadId?: ThreadId; + readonly resume?: string; + readonly resumeSessionAt?: string; + readonly turnCount?: number; +} + +interface ClaudeTurnState { + readonly turnId: TurnId; + readonly assistantItemId: string; + readonly startedAt: string; + readonly items: Array; + readonly messageCompleted: boolean; + readonly emittedTextDelta: boolean; + readonly fallbackAssistantText: string; +} + +interface PendingApproval { + readonly requestType: CanonicalRequestType; + readonly detail?: string; + readonly suggestions?: ReadonlyArray; + readonly decision: Deferred.Deferred; +} + +interface ToolInFlight { + readonly itemId: string; + readonly itemType: CanonicalItemType; + readonly toolName: string; + readonly title: string; + readonly detail?: string; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly promptQueue: Queue.Queue; + readonly query: ClaudeQueryRuntime; + readonly startedAt: string; + resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + readonly inFlightTools: Map; + turnState: ClaudeTurnState | undefined; + lastAssistantUuid: string | undefined; + lastThreadStartedId: string | undefined; + stopped: boolean; +} + +interface ClaudeQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; + readonly setModel: (model?: string) => Promise; + readonly setPermissionMode: (mode: PermissionMode) => Promise; + readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly close: () => void; +} + +export interface ClaudeCodeAdapterLiveOptions { + readonly createQuery?: (input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => ClaudeQueryRuntime; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function isSyntheticClaudeThreadId(value: string): boolean { + return value.startsWith("claude-thread-"); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toPermissionMode(value: unknown): PermissionMode | undefined { + switch (value) { + case "default": + case "acceptEdits": + case "bypassPermissions": + case "plan": + case "dontAsk": + return value; + default: + return undefined; + } +} + +function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const cursor = resumeCursor as { + threadId?: unknown; + resume?: unknown; + sessionId?: unknown; + resumeSessionAt?: unknown; + turnCount?: unknown; + }; + + const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; + const threadId = + threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) + ? ThreadId.makeUnsafe(threadIdCandidate) + : undefined; + const resumeCandidate = + typeof cursor.resume === "string" + ? cursor.resume + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined; + const resumeSessionAt = + typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; + const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + + return { + ...(threadId ? { threadId } : {}), + ...(resume ? { resume } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), + ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 + ? { turnCount: turnCountValue } + : {}), + }; +} + +function classifyToolItemType(toolName: string): CanonicalItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; +} + +function classifyRequestType(toolName: string): CanonicalRequestType { + const normalized = toolName.toLowerCase(); + if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + return "file_read_approval"; + } + return classifyToolItemType(toolName) === "command_execution" + ? "command_execution_approval" + : "file_change_approval"; +} + +function summarizeToolRequest(toolName: string, input: Record): string { + const commandValue = input.command ?? input.cmd; + const command = typeof commandValue === "string" ? commandValue : undefined; + if (command && command.trim().length > 0) { + return `${toolName}: ${command.trim().slice(0, 400)}`; + } + + const serialized = JSON.stringify(input); + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { + const fragments: string[] = []; + + if (input.input && input.input.trim().length > 0) { + fragments.push(input.input.trim()); + } + + for (const attachment of input.attachments ?? []) { + if (attachment.type === "image") { + fragments.push( + `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, + ); + } + } + + const text = fragments.join("\n\n"); + + return { + type: "user", + session_id: "", + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "text", text }], + }, + } as SDKUserMessage; +} + +function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { + if (result.subtype === "success") { + return "completed"; + } + + const errors = result.errors.join(" ").toLowerCase(); + if (errors.includes("interrupt")) { + return "interrupted"; + } + if (errors.includes("cancel")) { + return "cancelled"; + } + return "failed"; +} + +function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + +function providerThreadRef( + context: ClaudeSessionContext, +): { readonly providerThreadId: string } | {} { + return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +} + +function extractAssistantText(message: SDKMessage): string { + if (message.type !== "assistant") { + return ""; + } + + const content = (message.message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) { + return ""; + } + + const fragments: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const candidate = block as { type?: unknown; text?: unknown }; + if ( + candidate.type === "text" && + typeof candidate.text === "string" && + candidate.text.length > 0 + ) { + fragments.push(candidate.text); + } + } + + return fragments.join(""); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError( + threadId: ThreadId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function sdkMessageType(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { type?: unknown }; + return typeof record.type === "string" ? record.type : undefined; +} + +function sdkMessageSubtype(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as { subtype?: unknown }; + return typeof record.subtype === "string" ? record.subtype : undefined; +} + +function sdkNativeMethod(message: SDKMessage): string { + const subtype = sdkMessageSubtype(message); + if (subtype) { + return `claude/${message.type}/${subtype}`; + } + + if (message.type === "stream_event") { + const streamType = sdkMessageType(message.event); + if (streamType) { + const deltaType = + streamType === "content_block_delta" + ? sdkMessageType((message.event as { delta?: unknown }).delta) + : undefined; + if (deltaType) { + return `claude/${message.type}/${streamType}/${deltaType}`; + } + return `claude/${message.type}/${streamType}`; + } + } + + return `claude/${message.type}`; +} + +function sdkNativeItemId(message: SDKMessage): string | undefined { + if (message.type === "assistant") { + const maybeId = (message.message as { id?: unknown }).id; + if (typeof maybeId === "string") { + return maybeId; + } + return undefined; + } + + if (message.type === "stream_event") { + const event = message.event as { + type?: unknown; + content_block?: { id?: unknown }; + }; + if (event.type === "content_block_start" && typeof event.content_block?.id === "string") { + return event.content_block.id; + } + } + + return undefined; +} + +function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const createQuery = + options?.createQuery ?? + ((input: { + readonly prompt: AsyncIterable; + readonly options: ClaudeQueryOptions; + }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const logNativeSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (!nativeEventLogger) { + return; + } + + const observedAt = new Date().toISOString(); + const itemId = sdkNativeItemId(message); + + yield* nativeEventLogger + .write( + { + observedAt, + event: { + id: + "uuid" in message && typeof message.uuid === "string" + ? message.uuid + : crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method: sdkNativeMethod(message), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), + payload: message, + }, + }, + null, + ); + }); + + const snapshotThread = ( + context: ClaudeSessionContext, + ): Effect.Effect<{ + threadId: ThreadId; + turns: ReadonlyArray<{ + id: TurnId; + items: ReadonlyArray; + }>; + }, ProviderAdapterValidationError> => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "readThread", + issue: "Session thread id is not initialized yet.", + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect => + Effect.gen(function* () { + const threadId = context.session.threadId; + if (!threadId) return; + + const resumeCursor = { + threadId, + ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), + ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + turnCount: context.turns.length, + }; + + context.session = { + ...context.session, + resumeCursor, + updatedAt: yield* nowIso, + }; + }); + + const ensureThreadId = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (typeof message.session_id !== "string" || message.session_id.length === 0) { + return; + } + const nextThreadId = message.session_id; + context.resumeSessionId = message.session_id; + yield* updateResumeCursor(context); + + if (context.lastThreadStartedId !== nextThreadId) { + context.lastThreadStartedId = nextThreadId; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: {}, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, + }); + } + }); + + const emitRuntimeError = ( + context: ClaudeSessionContext, + message: string, + cause?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + if (cause !== undefined) { + void cause; + } + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), + }, + }); + }); + + const completeTurn = ( + context: ClaudeSessionContext, + status: ProviderRuntimeTurnStatus, + errorMessage?: string, + result?: SDKResultMessage, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: {}, + }); + return; + } + + if (!turnState.messageCompleted) { + if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: turnState.fallbackAssistantText, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: String(turnState.turnId), + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + itemId: asRuntimeItemId(turnState.assistantItemId), + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, + }); + } + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: turnState.turnId, + }, + }); + + const updatedAt = yield* nowIso; + context.turnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + }; + yield* updateResumeCursor(context); + }); + + const handleStreamEvent = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "stream_event") { + return; + } + + const { event } = message; + + if (event.type === "content_block_delta") { + if ( + event.delta.type === "text_delta" && + event.delta.text.length > 0 && + context.turnState + ) { + if (!context.turnState.emittedTextDelta) { + context.turnState = { + ...context.turnState, + emittedTextDelta: true, + }; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: streamKindFromDeltaType(event.delta.type), + delta: event.delta.text, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, + }); + } + return; + } + + if (event.type === "content_block_start") { + const { index, content_block: block } = event; + if ( + block.type !== "tool_use" && + block.type !== "server_tool_use" && + block.type !== "mcp_tool_use" + ) { + return; + } + + const toolName = block.name; + const itemType = classifyToolItemType(toolName); + const toolInput = + typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; + const itemId = block.id; + const detail = summarizeToolRequest(toolName, toolInput); + + const tool: ToolInFlight = { + itemId, + itemType, + toolName, + title: titleForTool(itemType), + detail, + }; + context.inFlightTools.set(index, tool); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + + if (event.type === "content_block_stop") { + const { index } = event; + const tool = context.inFlightTools.get(index); + if (!tool) { + return; + } + context.inFlightTools.delete(index); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(tool.itemId), + payload: { + itemType: tool.itemType, + status: "completed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_stop", + payload: message, + }, + }); + } + }); + + const handleAssistantMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "assistant") { + return; + } + + if (context.turnState) { + context.turnState.items.push(message.message); + const fallbackAssistantText = extractAssistantText(message); + if ( + fallbackAssistantText.length > 0 && + fallbackAssistantText !== context.turnState.fallbackAssistantText + ) { + context.turnState = { + ...context.turnState, + fallbackAssistantText, + }; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + data: message.message, + }, + providerRefs: { + ...providerThreadRef(context), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/assistant", + payload: message, + }, + }); + } + + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + }); + + const handleResultMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "result") { + return; + } + + const status = turnStatusFromResult(message); + const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + + if (status === "failed") { + yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + } + + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, + }, + }; + + switch (message.subtype) { + case "init": + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + payload: { + config: message as Record, + }, + }); + return; + case "status": + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, + }, + }); + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + }, + }); + return; + case "task_progress": + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + description: message.description, + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + }, + }); + return; + case "task_notification": + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(message.task_id), + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + }, + }); + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${message.subtype}'.`, + message, + ); + return; + } + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, + }, + }; + + if (message.type === "tool_progress") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + }, + }); + return; + } + + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } + + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } + + if (message.type === "rate_limit_event") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } + }); + + const handleSdkMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + yield* logNativeSdkMessage(context, message); + yield* ensureThreadId(context, message); + + switch (message.type) { + case "stream_event": + yield* handleStreamEvent(context, message); + return; + case "user": + return; + case "assistant": + yield* handleAssistantMessage(context, message); + return; + case "result": + yield* handleResultMessage(context, message); + return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit_event": + yield* handleSdkTelemetryMessage(context, message); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${message.type}'.`, + message, + ); + return; + } + }); + + const runSdkStream = (context: ClaudeSessionContext): Effect.Effect => + Stream.fromAsyncIterable(context.query, (cause) => cause).pipe( + Stream.takeWhile(() => !context.stopped), + Stream.runForEach((message) => handleSdkMessage(context, message)), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause) || context.stopped) { + return; + } + const message = toMessage(Cause.squash(cause), "Claude runtime stream failed."); + yield* emitRuntimeError(context, message, cause); + yield* completeTurn(context, "failed", message); + }), + ), + ); + + const stopSessionInternal = ( + context: ClaudeSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopped) return; + + context.stopped = true; + + for (const [requestId, pending] of context.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel"); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: { + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + }); + } + context.pendingApprovals.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped."); + } + + yield* Queue.shutdown(context.promptQueue); + + context.query.close(); + + const updatedAt = yield* nowIso; + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: {}, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: ClaudeCodeAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const resumeState = readClaudeResumeState(input.resumeCursor); + const threadId = input.threadId; + + const promptQueue = yield* Queue.unbounded(); + const prompt = Stream.fromQueue(promptQueue).pipe( + Stream.filter((item) => item.type === "message"), + Stream.map((item) => item.message), + Stream.toAsyncIterable, + ); + + const pendingApprovals = new Map(); + const inFlightTools = new Map(); + + const contextRef = yield* Ref.make(undefined); + + const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => + Effect.runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(contextRef); + if (!context) { + return { + behavior: "deny", + message: "Claude session context is unavailable.", + } satisfies PermissionResult; + } + + const runtimeMode = input.runtimeMode ?? "full-access"; + if (runtimeMode === "full-access") { + return { + behavior: "allow", + updatedInput: toolInput, + } satisfies PermissionResult; + } + + const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); + const requestType = classifyRequestType(toolName); + const detail = summarizeToolRequest(toolName, toolInput); + const decisionDeferred = yield* Deferred.make(); + const pendingApproval: PendingApproval = { + requestType, + detail, + decision: decisionDeferred, + ...(callbackOptions.suggestions + ? { suggestions: callbackOptions.suggestions } + : {}), + }; + + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + }, + providerRefs: { + ...(context.session.threadId + ? { providerThreadId: context.session.threadId } + : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); + + const providerOptions = input.providerOptions?.claudeCode; + const permissionMode = + toPermissionMode(providerOptions?.permissionMode) ?? + (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(providerOptions?.binaryPath + ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } + : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + includePartialMessages: true, + canUseTool, + env: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), + }); + + const session: ProviderSession = { + threadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(threadId ? { threadId } : {}), + resumeCursor: { + ...(threadId ? { threadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + promptQueue, + query: queryRuntime, + startedAt, + resumeSessionId: resumeState?.resume, + pendingApprovals, + turns: [], + inFlightTools, + turnState: undefined, + lastAssistantUuid: resumeState?.resumeSessionAt, + lastThreadStartedId: undefined, + stopped: false, + }; + yield* Ref.set(contextRef, context); + sessions.set(threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, + }); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + createdAt: configuredStamp.createdAt, + threadId, + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + }, + }, + providerRefs: {}, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", + }, + providerRefs: {}, + }); + + Effect.runFork(runSdkStream(context)); + + return { + ...session, + }; + }); + + const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, + }); + } + + if (input.model) { + yield* Effect.tryPromise({ + try: () => context.query.setModel(input.model), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + assistantItemId: yield* Random.nextUUIDv4, + startedAt: yield* nowIso, + items: [], + messageCompleted: false, + emittedTextDelta: false, + fallbackAssistantText: "", + }; + + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const message = buildUserMessage(input); + + yield* Queue.offer(context.promptQueue, { + type: "message", + message, + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }); + + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + }); + + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return yield* snapshotThread(context); + }); + + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + yield* updateResumeCursor(context); + return yield* snapshotThread(context); + }); + + const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/requestApproval/decision", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + + context.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "item/tool/requestUserInput", + detail: `Claude Code does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + + const stopAll: ClaudeCodeAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies ClaudeCodeAdapterShape; + }); +} + +export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter()); + +export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) { + return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2dd6062e60..eaba8097c7 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1,24 +1,24 @@ +import assert from "node:assert/strict"; import { ApprovalRequestId, EventId, ProviderItemId, type ProviderApprovalDecision, type ProviderEvent, - ProviderSessionId, type ProviderSession, - type ProviderSessionStartInput, - ProviderThreadId, - ProviderTurnId, type ProviderTurnStartResult, + type ProviderUserInputAnswers, + ThreadId, + TurnId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterAll, assert, it, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; +import { afterAll, it, vi } from "@effect/vitest"; import { Effect, Fiber, Layer, Option, Stream } from "effect"; import { CodexAppServerManager, + type CodexAppServerStartSessionInput, type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; @@ -27,20 +27,20 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); class FakeCodexManager extends CodexAppServerManager { public startSessionImpl = vi.fn( - async (input: ProviderSessionStartInput): Promise => { + async (input: CodexAppServerStartSessionInput): Promise => { const now = new Date().toISOString(); return { - sessionId: asSessionId("sess-1"), provider: "codex", status: "ready", - threadId: ProviderThreadId.makeUnsafe("thread-1"), + runtimeMode: input.runtimeMode, + threadId: input.threadId, cwd: input.cwd, createdAt: now, updatedAt: now, @@ -50,36 +50,44 @@ class FakeCodexManager extends CodexAppServerManager { public sendTurnImpl = vi.fn( async (_input: CodexAppServerSendTurnInput): Promise => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), }), ); public interruptTurnImpl = vi.fn( - async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise => undefined, + async (_threadId: ThreadId, _turnId?: TurnId): Promise => undefined, ); - public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({ + threadId: asThreadId("thread-1"), turns: [], })); - public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({ + threadId: asThreadId("thread-1"), turns: [], })); public respondToRequestImpl = vi.fn( async ( - _sessionId: ProviderSessionId, + _threadId: ThreadId, _requestId: ApprovalRequestId, _decision: ProviderApprovalDecision, ): Promise => undefined, ); + public respondToUserInputImpl = vi.fn( + async ( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ): Promise => undefined, + ); + public stopAllImpl = vi.fn(() => undefined); - override startSession(input: ProviderSessionStartInput): Promise { + override startSession(input: CodexAppServerStartSessionInput): Promise { return this.startSessionImpl(input); } @@ -87,33 +95,41 @@ class FakeCodexManager extends CodexAppServerManager { return this.sendTurnImpl(input); } - override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { - return this.interruptTurnImpl(sessionId, turnId); + override interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { + return this.interruptTurnImpl(threadId, turnId); } - override readThread(sessionId: ProviderSessionId) { - return this.readThreadImpl(sessionId); + override readThread(threadId: ThreadId) { + return this.readThreadImpl(threadId); } - override rollbackThread(sessionId: ProviderSessionId, numTurns: number) { - return this.rollbackThreadImpl(sessionId, numTurns); + override rollbackThread(threadId: ThreadId, numTurns: number) { + return this.rollbackThreadImpl(threadId, numTurns); } override respondToRequest( - sessionId: ProviderSessionId, + threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ): Promise { - return this.respondToRequestImpl(sessionId, requestId, decision); + return this.respondToRequestImpl(threadId, requestId, decision); + } + + override respondToUserInput( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ): Promise { + return this.respondToUserInputImpl(threadId, requestId, answers); } - override stopSession(_sessionId: ProviderSessionId): void {} + override stopSession(_threadId: ThreadId): void {} override listSessions(): ProviderSession[] { return []; } - override hasSession(_sessionId: ProviderSessionId): boolean { + override hasSession(_threadId: ThreadId): boolean { return false; } @@ -127,9 +143,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - getThreadId: () => Effect.succeed(Option.none()), remove: () => Effect.void, - listSessionIds: () => Effect.succeed([]), + listThreadIds: () => Effect.succeed([]), }); const validationManager = new FakeCodexManager(); @@ -148,11 +163,14 @@ validationLayer("CodexAdapterLive validation", (it) => { const result = yield* adapter .startSession({ provider: "claudeCode", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", }) .pipe(Effect.result); - assertFailure( - result, + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, new ProviderAdapterValidationError({ provider: "codex", operation: "startSession", @@ -162,6 +180,33 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); }), ); + + it.effect("maps codex model options before starting a session", () => + Effect.gen(function* () { + validationManager.startSessionImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* adapter.startSession({ + provider: "codex", + threadId: asThreadId("thread-1"), + model: "gpt-5.3-codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + runtimeMode: "full-access", + }); + + assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { + provider: "codex", + threadId: asThreadId("thread-1"), + model: "gpt-5.3-codex", + serviceTier: "fast", + runtimeMode: "full-access", + }); + }), + ); }); const sessionErrorManager = new FakeCodexManager(); @@ -182,7 +227,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .sendTurn({ - sessionId: asSessionId("sess-missing"), + threadId: asThreadId("sess-missing"), input: "hello", attachments: [], }) @@ -198,8 +243,38 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { return; } assert.equal(result.failure.provider, "codex"); - assert.equal(result.failure.sessionId, "sess-missing"); - assert.instanceOf(result.failure.cause, Error); + assert.equal(result.failure.threadId, "sess-missing"); + assert.equal(result.failure.cause instanceof Error, true); + }), + ); + + it.effect("maps codex model options before sending a turn", () => + Effect.gen(function* () { + sessionErrorManager.sendTurnImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* Effect.ignore( + adapter.sendTurn({ + threadId: asThreadId("sess-missing"), + input: "hello", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + attachments: [], + }), + ); + + assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], { + threadId: asThreadId("sess-missing"), + input: "hello", + model: "gpt-5.3-codex", + effort: "high", + serviceTier: "fast", + }); }), ); }); @@ -214,7 +289,7 @@ const lifecycleLayer = it.layer( ); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { - it.effect("maps completed agent message items to canonical message.completed events", () => + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); @@ -223,10 +298,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-msg-complete"), kind: "notification", provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), method: "item/completed", - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("msg_1"), payload: { @@ -244,12 +318,86 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "message.completed"); - if (firstEvent.value.type !== "message.completed") { + assert.equal(firstEvent.value.type, "item.completed"); + if (firstEvent.value.type !== "item.completed") { return; } assert.equal(firstEvent.value.itemId, "msg_1"); assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal(firstEvent.value.payload.itemType, "assistant_message"); + }), + ); + + it.effect("maps completed plan items to canonical proposed-plan completion events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: asEventId("evt-plan-complete"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("plan_1"), + payload: { + item: { + type: "Plan", + id: "plan_1", + text: "## Final plan\n\n- one\n- two", + }, + }, + }; + + 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, "turn.proposed.completed"); + if (firstEvent.value.type !== "turn.proposed.completed") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal(firstEvent.value.payload.planMarkdown, "## Final plan\n\n- one\n- two"); + }), + ); + + it.effect("maps plan deltas to canonical proposed-plan delta events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-plan-delta"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/plan/delta", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("plan_1"), + payload: { + delta: "## Final plan", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "turn.proposed.delta"); + if (firstEvent.value.type !== "turn.proposed.delta") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal(firstEvent.value.payload.delta, "## Final plan"); }), ); @@ -262,7 +410,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-session-closed"), kind: "session", provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "session/closed", message: "Session stopped", @@ -279,12 +427,277 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (firstEvent.value.type !== "session.exited") { return; } - assert.equal(firstEvent.value.sessionId, "sess-1"); - assert.equal(firstEvent.value.message, "Session stopped"); + assert.equal(firstEvent.value.threadId, "thread-1"); + assert.equal(firstEvent.value.payload.reason, "Session stopped"); + }), + ); + + it.effect("preserves 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-request-resolved"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "serverRequest/resolved", + requestId: ApprovalRequestId.makeUnsafe("req-1"), + payload: { + request: { + method: "item/commandExecution/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, "command_execution_approval"); + }), + ); + + it.effect("maps windowsSandbox/setupCompleted to session state and warning on failure", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + const event: ProviderEvent = { + id: asEventId("evt-windows-sandbox-failed"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "windowsSandbox/setupCompleted", + message: "Sandbox setup failed", + payload: { + success: false, + detail: "unsupported environment", + }, + }; + + lifecycleManager.emit("event", event); + const events = Array.from(yield* Fiber.join(eventsFiber)); + + assert.equal(events.length, 2); + + const firstEvent = events[0]; + const secondEvent = events[1]; + + assert.equal(firstEvent?.type, "session.state.changed"); + if (firstEvent?.type === "session.state.changed") { + assert.equal(firstEvent.payload.state, "error"); + assert.equal(firstEvent.payload.reason, "Sandbox setup failed"); + } + + assert.equal(secondEvent?.type, "runtime.warning"); + if (secondEvent?.type === "runtime.warning") { + assert.equal(secondEvent.payload.message, "Sandbox setup failed"); + } + }), + ); + + it.effect( + "maps requestUserInput requests and answered notifications to canonical user-input events", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-requested"), + kind: "request", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + } satisfies ProviderEvent); + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-resolved"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput/answered", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + answers: { + sandbox_mode: { + answers: ["workspace-write"], + }, + }, + }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.equal(events[0]?.type, "user-input.requested"); + if (events[0]?.type === "user-input.requested") { + assert.equal(events[0].requestId, "req-user-input-1"); + assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + } + + assert.equal(events[1]?.type, "user-input.resolved"); + if (events[1]?.type === "user-input.resolved") { + assert.equal(events[1].requestId, "req-user-input-1"); + assert.deepEqual(events[1].payload.answers, { + sandbox_mode: "workspace-write", + }); + } + }), + ); + + it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 5)).pipe( + Effect.forkChild, + ); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-task-started"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/task_started", + payload: { + id: "turn-structured-1", + msg: { + type: "task_started", + turn_id: "turn-structured-1", + collaboration_mode_kind: "plan", + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-agent-reasoning"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/agent_reasoning", + payload: { + id: "turn-structured-1", + msg: { + type: "agent_reasoning", + text: "Need to compare both transport layers before finalizing the plan.", + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-reasoning-delta"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/reasoning_content_delta", + payload: { + id: "turn-structured-1", + msg: { + type: "reasoning_content_delta", + turn_id: "turn-structured-1", + item_id: "rs_reasoning_1", + delta: "**Compare** transport boundaries", + summary_index: 0, + }, + }, + } satisfies ProviderEvent); + + lifecycleManager.emit("event", { + id: asEventId("evt-codex-task-complete"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/task_complete", + payload: { + id: "turn-structured-1", + msg: { + type: "task_complete", + turn_id: "turn-structured-1", + last_agent_message: "\n# Ship it\n", + }, + }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + + assert.equal(events[0]?.type, "task.started"); + if (events[0]?.type === "task.started") { + assert.equal(events[0].turnId, "turn-structured-1"); + assert.equal(events[0].payload.taskId, "turn-structured-1"); + assert.equal(events[0].payload.taskType, "plan"); + } + + assert.equal(events[1]?.type, "task.progress"); + if (events[1]?.type === "task.progress") { + assert.equal(events[1].payload.taskId, "turn-structured-1"); + assert.equal( + events[1].payload.description, + "Need to compare both transport layers before finalizing the plan.", + ); + } + + assert.equal(events[2]?.type, "content.delta"); + if (events[2]?.type === "content.delta") { + assert.equal(events[2].turnId, "turn-structured-1"); + assert.equal(events[2].itemId, "rs_reasoning_1"); + assert.equal(events[2].payload.streamKind, "reasoning_summary_text"); + assert.equal(events[2].payload.summaryIndex, 0); + } + + assert.equal(events[3]?.type, "task.completed"); + if (events[3]?.type === "task.completed") { + assert.equal(events[3].turnId, "turn-structured-1"); + assert.equal(events[3].payload.taskId, "turn-structured-1"); + assert.equal(events[3].payload.summary, "\n# Ship it\n"); + } + + assert.equal(events[4]?.type, "turn.proposed.completed"); + if (events[4]?.type === "turn.proposed.completed") { + assert.equal(events[4].turnId, "turn-structured-1"); + assert.equal(events[4].payload.planMarkdown, "# Ship it"); + } }), ); }); afterAll(() => { - assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1); + if (lifecycleManager.stopAllImpl.mock.calls.length === 0) { + lifecycleManager.stopAll(); + } + assert.ok(lifecycleManager.stopAllImpl.mock.calls.length >= 1); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8bec738893..d26fbe35b6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -7,17 +7,20 @@ * @module CodexAdapterLive */ import { + type CanonicalItemType, + type CanonicalRequestType, type ProviderEvent, type ProviderRuntimeEvent, - type ProviderRuntimeTurnStatus, - type ProviderRuntimeToolKind, - ProviderItemId, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, ProviderApprovalDecision, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, + ProviderItemId, + ThreadId, + TurnId, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -28,8 +31,10 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { + CodexAppServerManager, + type CodexAppServerStartSessionInput, +} from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -38,7 +43,7 @@ const PROVIDER = "codex" as const; export interface CodexAdapterLiveOptions { readonly manager?: CodexAppServerManager; - readonly makeManager?: () => CodexAppServerManager; + readonly makeManager?: (services?: ServiceMap.ServiceMap) => CodexAppServerManager; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -51,33 +56,29 @@ function toMessage(cause: unknown, fallback: string): string { } function toSessionError( - sessionId: ProviderSessionId, + threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } if (normalized.includes("session is closed")) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } return undefined; } -function toRequestError( - sessionId: ProviderSessionId, - method: string, - cause: unknown, -): ProviderAdapterError { - const sessionError = toSessionError(sessionId, cause); +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); if (sessionError) { return sessionError; } @@ -100,7 +101,15 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { switch (value) { case "completed": case "failed": @@ -108,7 +117,7 @@ function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { case "interrupted": return value; default: - return undefined; + return "completed"; } } @@ -123,29 +132,54 @@ function normalizeItemType(raw: unknown): string { .toLowerCase(); } -function shouldDropItemType(type: string): boolean { - if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { - return true; - } - return type === "work" || type.startsWith("work "); +function toCanonicalItemType(raw: unknown): CanonicalItemType { + const type = normalizeItemType(raw); + if (type.includes("user")) return "user_message"; + if (type.includes("agent message") || type.includes("assistant")) return "assistant_message"; + if (type.includes("reasoning") || type.includes("thought")) return "reasoning"; + if (type.includes("plan") || type.includes("todo")) return "plan"; + if (type.includes("command")) return "command_execution"; + if (type.includes("file change") || type.includes("patch") || type.includes("edit")) + return "file_change"; + if (type.includes("mcp")) return "mcp_tool_call"; + if (type.includes("dynamic tool")) return "dynamic_tool_call"; + if (type.includes("collab")) return "collab_agent_tool_call"; + if (type.includes("web search")) return "web_search"; + if (type.includes("image")) return "image_view"; + if (type.includes("review entered")) return "review_entered"; + if (type.includes("review exited")) return "review_exited"; + if (type.includes("compact")) return "context_compaction"; + if (type.includes("error")) return "error"; + return "unknown"; } -function toolMeta(type: string): - | { - readonly toolKind: ProviderRuntimeToolKind; - readonly title: string; - } - | undefined { - if (type.includes("command")) { - return { toolKind: "command", title: "Command run" }; - } - if (type.includes("file change")) { - return { toolKind: "file-change", title: "File change" }; - } - if (type.includes("tool")) { - return { toolKind: "other", title: "Tool call" }; +function itemTitle(itemType: CanonicalItemType): string | undefined { + switch (itemType) { + case "assistant_message": + return "Assistant message"; + case "user_message": + return "User message"; + case "reasoning": + return "Reasoning"; + case "plan": + return "Plan"; + 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"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + case "error": + return "Error"; + default: + return undefined; } - return undefined; } function itemDetail( @@ -174,84 +208,319 @@ function itemDetail( return undefined; } -function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { - if (event.method !== "item/completed") { - return undefined; +function toRequestTypeFromMethod(method: string): CanonicalRequestType { + switch (method) { + case "item/commandExecution/requestApproval": + return "command_execution_approval"; + case "item/fileRead/requestApproval": + return "file_read_approval"; + case "item/fileChange/requestApproval": + return "file_change_approval"; + case "applyPatchApproval": + return "apply_patch_approval"; + case "execCommandApproval": + return "exec_command_approval"; + case "item/tool/requestUserInput": + return "tool_user_input"; + case "item/tool/call": + return "dynamic_tool_call"; + case "account/chatgptAuthTokens/refresh": + return "auth_tokens_refresh"; + default: + return "unknown"; + } +} + +function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { + switch (kind) { + case "command": + return "command_execution_approval"; + case "file-read": + return "file_read_approval"; + case "file-change": + return "file_change_approval"; + default: + return "unknown"; } +} - const payload = asObject(event.payload); - const item = asObject(payload?.item); - if (!item) { - return undefined; +function toRequestTypeFromResolvedPayload( + payload: Record | undefined, +): CanonicalRequestType { + const request = asObject(payload?.request); + const method = asString(request?.method) ?? asString(payload?.method); + if (method) { + return toRequestTypeFromMethod(method); + } + const requestKind = asString(request?.kind) ?? asString(payload?.requestKind); + if (requestKind) { + return toRequestTypeFromKind(requestKind); } + return "unknown"; +} - const normalizedType = normalizeItemType(item.type ?? item.kind); - if (!normalizedType.includes("agent message")) { - return undefined; +function toCanonicalUserInputAnswers( + answers: ProviderUserInputAnswers | undefined, +): ProviderUserInputAnswers { + if (!answers) { + return {}; } - const itemId = event.itemId ?? asString(item.id); - if (!itemId) { + return Object.fromEntries( + Object.entries(answers).flatMap(([questionId, value]) => { + if (typeof value === "string") { + return [[questionId, value] as const]; + } + + 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]; + } + + const answerObject = asObject(value); + const answerList = asArray(answerObject?.answers)?.filter( + (entry): entry is string => typeof entry === "string", + ); + if (!answerList || answerList.length === 0) { + return []; + } + return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; + }), + ); +} + +function toUserInputQuestions(payload: Record | undefined) { + const questions = asArray(payload?.questions); + if (!questions) { return undefined; } + const parsedQuestions = questions + .map((entry) => { + const question = asObject(entry); + if (!question) return undefined; + const options = asArray(question.options) + ?.map((option) => { + const optionRecord = asObject(option); + if (!optionRecord) return undefined; + const label = asString(optionRecord.label)?.trim(); + const description = asString(optionRecord.description)?.trim(); + if (!label || !description) { + return undefined; + } + return { label, description }; + }) + .filter((option): option is { label: string; description: string } => option !== undefined); + const id = asString(question.id)?.trim(); + const header = asString(question.header)?.trim(); + const prompt = asString(question.question)?.trim(); + if (!id || !header || !prompt || !options || options.length === 0) { + return undefined; + } + return { + id, + header, + question: prompt, + options, + }; + }) + .filter( + ( + question, + ): question is { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + } => question !== undefined, + ); + + return parsedQuestions.length > 0 ? parsedQuestions : undefined; +} + +function toThreadState( + value: unknown, +): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { + switch (value) { + case "idle": + return "idle"; + case "archived": + return "archived"; + case "closed": + return "closed"; + case "compacted": + return "compacted"; + case "error": + case "failed": + return "error"; + default: + return "active"; + } +} + +function contentStreamKindFromMethod( + method: string, +): + | "assistant_text" + | "reasoning_text" + | "reasoning_summary_text" + | "plan_text" + | "command_output" + | "file_change_output" { + switch (method) { + case "item/agentMessage/delta": + return "assistant_text"; + case "item/reasoning/textDelta": + return "reasoning_text"; + case "item/reasoning/summaryTextDelta": + return "reasoning_summary_text"; + case "item/commandExecution/outputDelta": + return "command_output"; + case "item/fileChange/outputDelta": + return "file_change_output"; + default: + return "assistant_text"; + } +} + +const PROPOSED_PLAN_BLOCK_REGEX = /\s*([\s\S]*?)\s*<\/proposed_plan>/i; + +function extractProposedPlanMarkdown(text: string | undefined): string | undefined { + const match = text ? PROPOSED_PLAN_BLOCK_REGEX.exec(text) : null; + const planMarkdown = match?.[1]?.trim(); + return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined; +} + +function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { + return RuntimeItemId.makeUnsafe(itemId); +} + +function asRuntimeRequestId(requestId: string): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(requestId); +} + +function asRuntimeTaskId(taskId: string): RuntimeTaskId { + return RuntimeTaskId.makeUnsafe(taskId); +} + +function codexEventMessage(payload: Record | undefined): Record | undefined { + return asObject(payload?.msg); +} + +function codexEventBase( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): Omit { + const payload = asObject(event.payload); + const msg = codexEventMessage(payload); + const turnId = asString(msg?.turn_id) ?? asString(msg?.turnId); + const itemId = asString(msg?.item_id) ?? asString(msg?.itemId); + const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); + const base = runtimeEventBase(event, canonicalThreadId); + const providerRefs = base.providerRefs + ? { + ...base.providerRefs, + ...(turnId ? { providerTurnId: turnId } : {}), + ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(requestId ? { providerRequestId: requestId } : {}), + } + : { + ...(turnId ? { providerTurnId: turnId } : {}), + ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), + ...(requestId ? { providerRequestId: requestId } : {}), + }; + + return { + ...base, + ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}), + ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}), + ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), + ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), + }; +} + +function eventRawSource(event: ProviderEvent): NonNullable["source"] { + return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; +} + +function providerRefsFromEvent( + event: ProviderEvent, +): ProviderRuntimeEvent["providerRefs"] | undefined { + const refs: Record = {}; + if (event.turnId) refs.providerTurnId = event.turnId; + if (event.itemId) refs.providerItemId = event.itemId; + if (event.requestId) refs.providerRequestId = event.requestId; + + return Object.keys(refs).length > 0 ? (refs as ProviderRuntimeEvent["providerRefs"]) : undefined; +} + +function runtimeEventBase( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): Omit { + const refs = providerRefsFromEvent(event); return { - type: "message.completed", eventId: event.id, provider: event.provider, - sessionId: event.sessionId, + threadId: canonicalThreadId, createdAt: event.createdAt, - itemId: ProviderItemId.makeUnsafe(itemId), - ...(event.threadId ? { threadId: event.threadId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}), + ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}), + ...(refs ? { providerRefs: refs } : {}), + raw: { + source: eventRawSource(event), + method: event.method, + payload: event.payload ?? {}, + }, }; } -function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { - if (event.method !== "item/started" && event.method !== "item/completed") { - return undefined; - } +function mapItemLifecycle( + event: ProviderEvent, + canonicalThreadId: ThreadId, + lifecycle: "item.started" | "item.updated" | "item.completed", +): ProviderRuntimeEvent | undefined { const payload = asObject(event.payload); const item = asObject(payload?.item); - if (!item) { + const source = item ?? payload; + if (!source) { return undefined; } - const normalizedType = normalizeItemType(item.type ?? item.kind); - if (shouldDropItemType(normalizedType)) { - return undefined; - } - const meta = toolMeta(normalizedType); - if (!meta) { + + const itemType = toCanonicalItemType(source.type ?? source.kind); + if (itemType === "unknown" && lifecycle !== "item.updated") { return undefined; } - const eventBase = { - eventId: event.id, - provider: event.provider, - sessionId: event.sessionId, - createdAt: event.createdAt, - ...(event.threadId ? { threadId: event.threadId } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), - ...(event.itemId ? { itemId: event.itemId } : {}), - toolKind: meta.toolKind, - title: meta.title, - ...(payload ? { detail: itemDetail(item, payload) } : {}), - } as const; - - if (event.method === "item/started") { - return { - type: "tool.started", - ...eventBase, - }; - } + const detail = itemDetail(source, payload ?? {}); + const status = + lifecycle === "item.started" + ? "inProgress" + : lifecycle === "item.completed" + ? "completed" + : undefined; return { - type: "tool.completed", - ...eventBase, + ...runtimeEventBase(event, canonicalThreadId), + type: lifecycle, + payload: { + itemType, + ...(status ? { status } : {}), + ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(detail ? { detail } : {}), + ...(event.payload !== undefined ? { data: event.payload } : {}), + }, }; } -function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray { +function mapToRuntimeEvents( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { const payload = asObject(event.payload); const turn = asObject(payload?.turn); @@ -261,78 +530,103 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray asObject(entry)) + .filter((entry): entry is Record => entry !== undefined) + .map((entry) => ({ + step: asString(entry.step) ?? "step", + status: + entry.status === "completed" || entry.status === "inProgress" + ? entry.status + : "pending", + })), + }, + }, + ]; + } + + if (event.method === "turn/diff/updated") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "turn.diff.updated", + payload: { + unifiedDiff: + asString(payload?.unifiedDiff) ?? + asString(payload?.diff) ?? + asString(payload?.patch) ?? + "", + }, + }, + ]; + } + + if (event.method === "item/started") { + const started = mapItemLifecycle(event, canonicalThreadId, "item.started"); + return started ? [started] : []; + } + + if (event.method === "item/completed") { + const payload = asObject(event.payload); + const item = asObject(payload?.item); + const source = item ?? payload; + if (!source) { + return []; + } + const itemType = source ? toCanonicalItemType(source.type ?? source.kind) : "unknown"; + if (itemType === "plan") { + const detail = itemDetail(source, payload ?? {}); + if (!detail) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "turn.proposed.completed", + payload: { + planMarkdown: detail, + }, + }, + ]; + } + const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); + return completed ? [completed] : []; + } + + if ( + event.method === "item/reasoning/summaryPartAdded" || + event.method === "item/commandExecution/terminalInteraction" + ) { + const updated = mapItemLifecycle(event, canonicalThreadId, "item.updated"); + return updated ? [updated] : []; + } + + if (event.method === "item/plan/delta") { + const delta = + event.textDelta ?? + asString(payload?.delta) ?? + asString(payload?.text) ?? + asString(asObject(payload?.content)?.text); + if (!delta || delta.length === 0) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "turn.proposed.delta", + payload: { + delta, + }, + }, + ]; + } + + if ( + event.method === "item/agentMessage/delta" || + event.method === "item/commandExecution/outputDelta" || + event.method === "item/fileChange/outputDelta" || + event.method === "item/reasoning/summaryTextDelta" || + event.method === "item/reasoning/textDelta" + ) { + const delta = + event.textDelta ?? + asString(payload?.delta) ?? + asString(payload?.text) ?? + asString(asObject(payload?.content)?.text); + if (!delta || delta.length === 0) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "content.delta", + payload: { + streamKind: contentStreamKindFromMethod(event.method), + delta, + ...(typeof payload?.contentIndex === "number" + ? { contentIndex: payload.contentIndex } + : {}), + ...(typeof payload?.summaryIndex === "number" + ? { summaryIndex: payload.summaryIndex } + : {}), + }, + }, + ]; + } + + if (event.method === "item/mcpToolCall/progress") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "tool.progress", + payload: { + ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}), + ...(asString(payload?.toolName) ? { toolName: asString(payload?.toolName) } : {}), + ...(asString(payload?.summary) ? { summary: asString(payload?.summary) } : {}), + ...(asNumber(payload?.elapsedSeconds) !== undefined + ? { elapsedSeconds: asNumber(payload?.elapsedSeconds) } + : {}), + }, + }, + ]; + } + + if (event.method === "serverRequest/resolved") { + const requestType = + toRequestTypeFromResolvedPayload(payload) !== "unknown" + ? toRequestTypeFromResolvedPayload(payload) + : event.requestId && event.requestKind !== undefined + ? toRequestTypeFromKind(event.requestKind) + : "unknown"; + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "request.resolved", + payload: { + requestType, + ...(event.payload !== undefined ? { resolution: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "item/tool/requestUserInput/answered") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "user-input.resolved", + payload: { + answers: toCanonicalUserInputAnswers( + asObject(event.payload)?.answers as ProviderUserInputAnswers | undefined, + ), + }, + }, + ]; + } + + if (event.method === "codex/event/task_started") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id) ?? asString(msg?.turn_id); + if (!taskId) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.started", + payload: { + taskId: asRuntimeTaskId(taskId), + ...(asString(msg?.collaboration_mode_kind) + ? { taskType: asString(msg?.collaboration_mode_kind) } + : {}), + }, + }, + ]; + } + + if (event.method === "codex/event/task_complete") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id) ?? asString(msg?.turn_id); + const proposedPlanMarkdown = extractProposedPlanMarkdown(asString(msg?.last_agent_message)); + if (!taskId) { + if (!proposedPlanMarkdown) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "turn.proposed.completed", + payload: { + planMarkdown: proposedPlanMarkdown, + }, + }, + ]; + } + const events: ProviderRuntimeEvent[] = [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.completed", + payload: { + taskId: asRuntimeTaskId(taskId), + status: "completed", + ...(asString(msg?.last_agent_message) + ? { summary: asString(msg?.last_agent_message) } + : {}), + }, + }, + ]; + if (proposedPlanMarkdown) { + events.push({ + ...codexEventBase(event, canonicalThreadId), + type: "turn.proposed.completed", + payload: { + planMarkdown: proposedPlanMarkdown, + }, + }); + } + return events; + } + + if (event.method === "codex/event/agent_reasoning") { + const msg = codexEventMessage(payload); + const taskId = asString(payload?.id); + const description = asString(msg?.text); + if (!taskId || !description) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "task.progress", + payload: { + taskId: asRuntimeTaskId(taskId), + description, + }, + }, + ]; + } + + if (event.method === "codex/event/reasoning_content_delta") { + const msg = codexEventMessage(payload); + const delta = asString(msg?.delta); + if (!delta) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "content.delta", + payload: { + streamKind: + asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text", + delta, + ...(asNumber(msg?.summary_index) !== undefined + ? { summaryIndex: asNumber(msg?.summary_index) } + : {}), + }, + }, + ]; + } + + if (event.method === "model/rerouted") { + return [ + { + type: "model.rerouted", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + fromModel: asString(payload?.fromModel) ?? "unknown", + toModel: asString(payload?.toModel) ?? "unknown", + reason: asString(payload?.reason) ?? "unknown", + }, + }, + ]; + } + + if (event.method === "deprecationNotice") { + return [ + { + type: "deprecation.notice", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + summary: asString(payload?.summary) ?? "Deprecation notice", + ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), + }, + }, + ]; + } + + if (event.method === "configWarning") { + return [ + { + type: "config.warning", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + summary: asString(payload?.summary) ?? "Configuration warning", + ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), + ...(asString(payload?.path) ? { path: asString(payload?.path) } : {}), + ...(payload?.range !== undefined ? { range: payload.range } : {}), + }, }, ]; } - if (event.method === "item/agentMessage/delta" && event.textDelta && event.textDelta.length > 0) { + if (event.method === "account/updated") { return [ { - type: "message.delta", - eventId: event.id, - provider: event.provider, - sessionId: event.sessionId, - createdAt: event.createdAt, - ...(event.threadId ? { threadId: event.threadId } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), - ...(event.itemId ? { itemId: event.itemId } : {}), - delta: event.textDelta, + type: "account.updated", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + account: event.payload ?? {}, + }, }, ]; } + if (event.method === "account/rateLimits/updated") { + return [ + { + type: "account.rate-limits.updated", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + rateLimits: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "mcpServer/oauthLogin/completed") { + return [ + { + type: "mcp.oauth.completed", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + success: payload?.success === true, + ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}), + ...(asString(payload?.error) ? { error: asString(payload?.error) } : {}), + }, + }, + ]; + } + + if (event.method === "thread/realtime/started") { + const realtimeSessionId = asString(payload?.realtimeSessionId); + return [ + { + type: "thread.realtime.started", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + realtimeSessionId, + }, + }, + ]; + } + + if (event.method === "thread/realtime/itemAdded") { + return [ + { + type: "thread.realtime.item-added", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + item: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "thread/realtime/outputAudio/delta") { + return [ + { + type: "thread.realtime.audio.delta", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + audio: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "thread/realtime/error") { + const message = asString(payload?.message) ?? event.message ?? "Realtime error"; + return [ + { + type: "thread.realtime.error", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message, + }, + }, + ]; + } + + if (event.method === "thread/realtime/closed") { + return [ + { + type: "thread.realtime.closed", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + reason: event.message, + }, + }, + ]; + } + + if (event.method === "error") { + const message = + asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; + return [ + { + type: "runtime.error", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message, + class: "provider_error", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "windows/worldWritableWarning") { + return [ + { + type: "runtime.warning", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message: event.message ?? "Windows world-writable warning", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "windowsSandbox/setupCompleted") { + const payloadRecord = asObject(event.payload); + const success = payloadRecord?.success; + const successMessage = event.message ?? "Windows sandbox setup completed"; + const failureMessage = event.message ?? "Windows sandbox setup failed"; + + return [ + { + type: "session.state.changed", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + state: success === false ? "error" : "ready", + reason: success === false ? failureMessage : successMessage, + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ...(success === false + ? [ + { + type: "runtime.warning" as const, + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message: failureMessage, + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ] + : []), + ]; + } + return []; } @@ -432,7 +1259,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* Effect.service(ServerConfig); - const directory = yield* ProviderSessionDirectory; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -442,14 +1268,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : undefined); const manager = yield* Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { if (options?.manager) { return options.manager; } - if (options?.makeManager) { - return options.makeManager(); - } - return new CodexAppServerManager(); + const services = yield* Effect.services(); + return options?.makeManager?.(services) ?? new CodexAppServerManager(services); }), (manager) => Effect.sync(() => { @@ -472,16 +1296,29 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => ); } + const managerInput: CodexAppServerStartSessionInput = { + threadId: input.threadId, + provider: "codex", + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + runtimeMode: input.runtimeMode, + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), + }; + return Effect.tryPromise({ - try: () => manager.startSession(input), + try: () => manager.startSession(managerInput), catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, - sessionId: "pending", + threadId: input.threadId, detail: toMessage(cause, "Failed to start Codex adapter session."), cause, }), - }); + }).pipe( + Effect.map((session) => session), + ); }; const sendTurn: CodexAdapterShape["sendTurn"] = (input) => @@ -496,7 +1333,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }); if (!attachmentPath) { return yield* toRequestError( - input.sessionId, + input.threadId, "turn/start", new Error(`Invalid attachment id '${attachment.id}'.`), ); @@ -504,7 +1341,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const bytes = yield* fileSystem .readFile(attachmentPath) .pipe( - Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), + Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)), ); return { type: "image" as const, @@ -517,31 +1354,47 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => return yield* Effect.tryPromise({ try: () => { const managerInput = { - sessionId: input.sessionId, + threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(input.modelOptions?.codex?.reasoningEffort !== undefined + ? { effort: input.modelOptions.codex.reasoningEffort } + : {}), + ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), + ...(input.interactionMode !== undefined + ? { interactionMode: input.interactionMode } + : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }; return manager.sendTurn(managerInput); }, - catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), - }); + catch: (cause) => toRequestError(input.threadId, "turn/start", cause), + }).pipe( + Effect.map((result) => ({ + ...result, + threadId: input.threadId, + })), + ); }); - const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => + const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => Effect.tryPromise({ - try: () => manager.interruptTurn(sessionId, turnId), - catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), + try: () => manager.interruptTurn(threadId, turnId), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), }); - const readThread: CodexAdapterShape["readThread"] = (sessionId) => + const readThread: CodexAdapterShape["readThread"] = (threadId) => Effect.tryPromise({ - try: () => manager.readThread(sessionId), - catch: (cause) => toRequestError(sessionId, "thread/read", cause), - }); + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "thread/read", cause), + }).pipe( + Effect.map((snapshot) => ({ + threadId, + turns: snapshot.turns, + })), + ); - const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => { + const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { if (!Number.isInteger(numTurns) || numTurns < 1) { return Effect.fail( new ProviderAdapterValidationError({ @@ -553,31 +1406,46 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => } return Effect.tryPromise({ - try: () => manager.rollbackThread(sessionId, numTurns), - catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), - }); + try: () => manager.rollbackThread(threadId, numTurns), + catch: (cause) => toRequestError(threadId, "thread/rollback", cause), + }).pipe( + Effect.map((snapshot) => ({ + threadId, + turns: snapshot.turns, + })), + ); }; const respondToRequest: CodexAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => Effect.tryPromise({ - try: () => manager.respondToRequest(sessionId, requestId, decision), - catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), + }); + + const respondToUserInput: CodexAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.tryPromise({ + try: () => manager.respondToUserInput(threadId, requestId, answers), + catch: (cause) => toRequestError(threadId, "item/tool/requestUserInput", cause), }); - const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => + const stopSession: CodexAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { - manager.stopSession(sessionId); + manager.stopSession(threadId); }); const listSessions: CodexAdapterShape["listSessions"] = () => Effect.sync(() => manager.listSessions()); - const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => - Effect.sync(() => manager.hasSession(sessionId)); + const hasSession: CodexAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => manager.hasSession(threadId)); const stopAll: CodexAdapterShape["stopAll"] = () => Effect.sync(() => { @@ -593,22 +1461,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => if (!nativeEventLogger) { return; } - const orchestrationThreadId = yield* Effect.catch( - directory.getThreadId(event.sessionId).pipe( - Effect.map((threadIdOption) => - Option.isSome(threadIdOption) ? threadIdOption.value : null, - ), - ), - () => Effect.succeed(null), - ); - yield* nativeEventLogger.write(event, orchestrationThreadId); + yield* nativeEventLogger.write(event, event.threadId); }); const services = yield* Effect.services(); const listener = (event: ProviderEvent) => Effect.gen(function* () { yield* writeNativeEvent(event); - yield* Queue.offerAll(runtimeEventQueue, mapToRuntimeEvents(event)); + const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + if (runtimeEvents.length === 0) { + yield* Effect.logDebug("ignoring unhandled Codex provider event", { + method: event.method, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.itemId, + }); + return; + } + yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }).pipe(Effect.runPromiseWith(services)); manager.on("event", listener); return listener; @@ -624,12 +1494,16 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => return { provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, readThread, rollbackThread, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 29cfe9b5f3..795c106f05 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -12,10 +13,29 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; const fakeCodexAdapter: CodexAdapterShape = { provider: "codex", + 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 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(), @@ -27,7 +47,13 @@ const fakeCodexAdapter: CodexAdapterShape = { const layer = it.layer( Layer.mergeAll( - Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide( + ProviderAdapterRegistryLive, + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + ), + ), NodeServices.layer, ), ); @@ -36,11 +62,13 @@ layer("ProviderAdapterRegistryLive", (it) => { it.effect("resolves a registered provider adapter", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; - const adapter = yield* registry.getByProvider("codex"); - assert.equal(adapter, fakeCodexAdapter); + const codex = yield* registry.getByProvider("codex"); + const claude = yield* registry.getByProvider("claudeCode"); + assert.equal(codex, fakeCodexAdapter); + assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "claudeCode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index e59d85d0a0..61fa2d18cd 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -1,7 +1,7 @@ /** * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. * - * Binds provider kinds (codex/claudeCode/...) to concrete adapter services. + * Binds provider kinds (codex/cursor/...) to concrete adapter services. * This layer only performs adapter lookup; it does not route session-scoped * calls or own provider lifecycle workflows. * @@ -15,6 +15,7 @@ import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -23,7 +24,10 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => Effect.gen(function* () { - const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const adapters = + options?.adapters !== undefined + ? options.adapters + : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b18a838939..1e25bee626 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -12,11 +12,10 @@ import type { import { ApprovalRequestId, EventId, + type ProviderKind, ProviderSessionStartInput, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; @@ -26,9 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProviderAdapterSessionNotFoundError, - ProviderSessionNotFoundError, - ProviderValidationError, ProviderUnsupportedError, + ProviderValidationError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -45,33 +43,42 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); - -function makeFakeCodexAdapter() { - const sessions = new Map(); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); + +type LegacyProviderRuntimeEvent = { + readonly type: string; + readonly eventId: EventId; + readonly provider: "codex" | "claudeCode" | "cursor"; + readonly createdAt: string; + readonly threadId: ThreadId; + readonly turnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly payload?: unknown | undefined; + readonly [key: string]: unknown; +}; + +function makeFakeCodexAdapter(provider: ProviderKind = "codex") { + const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - let nextSession = 1; const startSession = vi.fn((input: ProviderSessionStartInput) => Effect.sync(() => { const now = new Date().toISOString(); - const next = nextSession; const session: ProviderSession = { - sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), - provider: "codex", + provider, status: "ready", - threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), + runtimeMode: input.runtimeMode, + threadId: input.threadId, + resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, }; - nextSession += 1; - sessions.set(session.sessionId, session); + sessions.set(session.threadId, session); return session; }), ); @@ -80,39 +87,47 @@ function makeFakeCodexAdapter() { ( input: ProviderSendTurnInput, ): Effect.Effect => { - if (!sessions.has(input.sessionId)) { + if (!sessions.has(input.threadId)) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ - provider: "codex", - sessionId: input.sessionId, + provider, + threadId: input.threadId, }), ); } return Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), - turnId: ProviderTurnId.makeUnsafe("turn-1"), + threadId: input.threadId, + turnId: TurnId.makeUnsafe(`turn-${String(input.threadId)}`), }); }, ); const interruptTurn = vi.fn( - (_sessionId: string, _turnId?: string): Effect.Effect => + (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect => Effect.void, ); const respondToRequest = vi.fn( ( - _sessionId: string, + _threadId: ThreadId, _requestId: string, _decision: ProviderApprovalDecision, ): Effect.Effect => Effect.void, ); + const respondToUserInput = vi.fn( + ( + _threadId: ThreadId, + _requestId: string, + _answers: Record, + ): Effect.Effect => Effect.void, + ); + const stopSession = vi.fn( - (sessionId: string): Effect.Effect => + (threadId: ThreadId): Effect.Effect => Effect.sync(() => { - sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); + sessions.delete(threadId); }), ); @@ -122,32 +137,31 @@ function makeFakeCodexAdapter() { ); const hasSession = vi.fn( - (sessionId: string): Effect.Effect => - Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), + (threadId: ThreadId): Effect.Effect => Effect.succeed(sessions.has(threadId)), ); const readThread = vi.fn( ( - _sessionId: ProviderSessionId, + threadId: ThreadId, ): Effect.Effect< { - threadId: ProviderThreadId; - turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; + threadId: ThreadId; + turns: ReadonlyArray<{ id: TurnId; items: readonly [] }>; }, ProviderAdapterError > => Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId, turns: [{ id: asTurnId("turn-1"), items: [] }], }), ); const rollbackThread = vi.fn( ( - _sessionId: ProviderSessionId, + threadId: ThreadId, _numTurns: number, - ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => - Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), + ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> => + Effect.succeed({ threadId, turns: [] }), ); const stopAll = vi.fn( @@ -158,11 +172,15 @@ function makeFakeCodexAdapter() { ); const adapter: ProviderAdapterShape = { - provider: "codex", + provider, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, @@ -172,8 +190,8 @@ function makeFakeCodexAdapter() { streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; - const emit = (event: ProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); + const emit = (event: LegacyProviderRuntimeEvent): void => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); }; return { @@ -183,6 +201,7 @@ function makeFakeCodexAdapter() { sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, hasSession, @@ -197,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) + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -225,6 +247,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -253,7 +276,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId: asSessionId("sess-stale"), provider: "codex", threadId: ThreadId.makeUnsafe("thread-stale"), }); @@ -270,13 +292,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const persistedProvider = yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - return yield* directory.getProvider(asSessionId("sess-stale")); + return yield* directory.getProvider(asThreadId("thread-stale")); }).pipe(Effect.provide(directoryLayer)); assert.equal(persistedProvider, "codex"); const runtime = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") }); + return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(runtime), true); @@ -324,9 +346,12 @@ it.effect( const startedSession = yield* Effect.gen(function* () { const provider = yield* ProviderService; - return yield* provider.startSession(asThreadId("thread-1"), { + const threadId = asThreadId("thread-1"); + return yield* provider.startSession(threadId, { provider: "codex", cwd: "/tmp/project", + runtimeMode: "full-access", + threadId, }); }).pipe(Effect.provide(firstProviderLayer)); @@ -337,14 +362,12 @@ it.effect( const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getBySessionId({ - providerSessionId: startedSession.sessionId, - }); + return yield* repository.getByThreadId({ threadId: startedSession.threadId }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { assert.equal(persistedAfterStopAll.value.status, "stopped"); - assert.equal(persistedAfterStopAll.value.resumeCursor, null); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); } const secondCodex = makeFakeCodexAdapter(); @@ -369,20 +392,26 @@ it.effect( yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.rollbackConversation({ - sessionId: startedSession.sessionId, + threadId: startedSession.threadId, numTurns: 1, }); }).pipe(Effect.provide(secondProviderLayer)); - assert.deepEqual(secondCodex.startSession.mock.calls, [ - [ - { - provider: "codex", - cwd: "/tmp/project", - resumeThreadId: startedSession.threadId, - }, - ], - ]); + assert.equal(secondCodex.startSession.mock.calls.length, 1); + const resumedStartInput = secondCodex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project"); + assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); + assert.equal(startPayload.threadId, startedSession.threadId); + } assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; assert.equal(typeof rollbackCall?.[0], "string"); @@ -399,7 +428,9 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project", + runtimeMode: "full-access", }); assert.equal(session.provider, "codex"); @@ -407,40 +438,60 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(sessions.length, 1); yield* provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); assert.equal(routing.codex.sendTurn.mock.calls.length, 1); - yield* provider.interruptTurn({ sessionId: session.sessionId }); - assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]); + yield* provider.interruptTurn({ threadId: session.threadId }); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); yield* provider.respondToRequest({ - sessionId: session.sessionId, + threadId: session.threadId, requestId: asRequestId("req-1"), decision: "accept", }); assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ - [session.sessionId, asRequestId("req-1"), "accept"], + [session.threadId, asRequestId("req-1"), "accept"], + ]); + + yield* provider.respondToUserInput({ + threadId: session.threadId, + requestId: asRequestId("req-user-input-1"), + answers: { + sandbox_mode: "workspace-write", + }, + }); + assert.deepEqual(routing.codex.respondToUserInput.mock.calls, [ + [ + session.threadId, + asRequestId("req-user-input-1"), + { + sandbox_mode: "workspace-write", + }, + ], ]); yield* provider.rollbackConversation({ - sessionId: session.sessionId, + threadId: session.threadId, numTurns: 0, }); - yield* provider.stopSession({ sessionId: session.sessionId }); + yield* provider.stopSession({ threadId: session.threadId }); const sendAfterStop = yield* Effect.result( provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "after-stop", attachments: [], }), ); assertFailure( sendAfterStop, - new ProviderSessionNotFoundError({ sessionId: session.sessionId }), + new ProviderValidationError({ + operation: "ProviderService.sendTurn", + issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, + }), ); }), ); @@ -451,39 +502,72 @@ routing.layer("ProviderServiceLive routing", (it) => { const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project", + runtimeMode: "full-access", }); - yield* routing.codex.stopSession(initial.sessionId); + yield* routing.codex.stopSession(initial.threadId); routing.codex.startSession.mockClear(); routing.codex.rollbackThread.mockClear(); yield* provider.rollbackConversation({ - sessionId: initial.sessionId, + threadId: initial.threadId, numTurns: 1, }); - assert.deepEqual(routing.codex.startSession.mock.calls, [ - [ - { - provider: "codex", - cwd: "/tmp/project", - resumeThreadId: initial.threadId, - }, - ], - ]); + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; assert.equal(rollbackCall?.[1], 1); }), ); + 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; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project-send-turn", + runtimeMode: "full-access", }); yield* provider.stopAll(); @@ -491,20 +575,26 @@ routing.layer("ProviderServiceLive routing", (it) => { routing.codex.sendTurn.mockClear(); yield* provider.sendTurn({ - sessionId: initial.sessionId, + threadId: initial.threadId, input: "resume", attachments: [], }); - assert.deepEqual(routing.codex.startSession.mock.calls, [ - [ - { - provider: "codex", - cwd: "/tmp/project-send-turn", - resumeThreadId: initial.threadId, - }, - ], - ]); + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project-send-turn"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } assert.equal(routing.codex.sendTurn.mock.calls.length, 1); }), ); @@ -515,9 +605,13 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", }); yield* provider.startSession(asThreadId("thread-2"), { provider: "codex", + threadId: asThreadId("thread-2"), + runtimeMode: "full-access", }); yield* provider.stopAll(); @@ -534,20 +628,22 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", }); yield* provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); - const runningRuntime = yield* runtimeRepository.getBySessionId({ - providerSessionId: session.sessionId, + const runningRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, }); assert.equal(Option.isSome(runningRuntime), true); if (Option.isSome(runningRuntime)) { assert.equal(runningRuntime.value.status, "running"); - assert.equal(runningRuntime.value.resumeCursor, null); + assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor); const payload = runningRuntime.value.runtimePayload; assert.equal(payload !== null && typeof payload === "object", true); if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { @@ -560,15 +656,15 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(runtimePayload.cwd, process.cwd()); assert.equal(runtimePayload.model, null); - assert.equal(runtimePayload.activeTurnId, "turn-1"); + assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } } yield* provider.stopAll(); - const stoppedRuntime = yield* runtimeRepository.getBySessionId({ - providerSessionId: session.sessionId, + const stoppedRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, }); assert.equal(Option.isSome(stoppedRuntime), true); if (Option.isSome(stoppedRuntime)) { @@ -585,6 +681,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", }); const eventsRef = yield* Ref.make>([]); @@ -593,13 +691,12 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ).pipe(Effect.forkChild); yield* sleep(20); - const completedEvent: ProviderRuntimeEvent = { + const completedEvent: LegacyProviderRuntimeEvent = { type: "turn.completed", eventId: asEventId("evt-1"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }; @@ -617,11 +714,68 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("fans out canonical runtime events in emission order", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession(asThreadId("thread-seq"), { + provider: "codex", + threadId: asThreadId("thread-seq"), + runtimeMode: "full-access", + }); + + const receivedRef = yield* Ref.make>([]); + const consumer = yield* Stream.take(provider.streamEvents, 3).pipe( + Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])), + Effect.forkChild, + ); + yield* sleep(20); + + fanout.codex.emit({ + type: "tool.started", + eventId: asEventId("evt-seq-1"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + toolKind: "command", + title: "Command run", + }); + fanout.codex.emit({ + type: "tool.completed", + eventId: asEventId("evt-seq-2"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + toolKind: "command", + title: "Command run", + }); + fanout.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-seq-3"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + status: "completed", + }); + + yield* Fiber.join(consumer); + const received = yield* Ref.get(receivedRef); + assert.deepEqual( + received.map((event) => event.eventId), + [asEventId("evt-seq-1"), asEventId("evt-seq-2"), asEventId("evt-seq-3")], + ); + }), + ); + it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", }); const receivedByHealthy: string[] = []; @@ -640,14 +794,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { ); yield* sleep(20); - const events: ReadonlyArray = [ + const events: ReadonlyArray = [ { type: "tool.completed", eventId: asEventId("evt-ordered-1"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", @@ -657,9 +810,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "message.delta", eventId: asEventId("evt-ordered-2"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), delta: "hello", }, @@ -667,9 +819,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "turn.completed", eventId: asEventId("evt-ordered-3"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }, @@ -698,7 +849,9 @@ validation.layer("ProviderServiceLive validation", (it) => { const failure = yield* Effect.result( provider.startSession(asThreadId("thread-validation"), { + threadId: asThreadId("thread-validation"), provider: "invalid-provider", + runtimeMode: "full-access", } as never), ); @@ -715,17 +868,19 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); - it.effect("fails startSession when adapter returns no threadId", () => + it.effect("accepts startSession when adapter has not emitted provider thread id yet", () => Effect.gen(function* () { const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { const now = new Date().toISOString(); return { - sessionId: asSessionId("sess-missing-thread"), provider: "codex", status: "ready", + threadId: input.threadId, + runtimeMode: input.runtimeMode, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -733,20 +888,22 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); - const failure = yield* Effect.result( - provider.startSession(asThreadId("thread-missing"), { - provider: "codex", - cwd: "/tmp/project", - }), - ); + const session = yield* provider.startSession(asThreadId("thread-missing"), { + provider: "codex", + threadId: asThreadId("thread-missing"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); - assertFailure( - failure, - new ProviderValidationError({ - operation: "ProviderService.startSession", - issue: "Provider 'codex' returned a session without threadId.", - }), - ); + assert.equal(session.threadId, asThreadId("thread-missing")); + + const runtime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.threadId, session.threadId); + } }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index bb58a7d93f..3681d5b8fe 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -11,24 +11,24 @@ */ import { NonNegativeInt, - ProviderSessionId, ThreadId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, + ProviderRespondToUserInputInput, ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect"; +import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; -import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory, - type ProviderSessionBinding, + type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -38,7 +38,7 @@ export interface ProviderServiceLiveOptions { } const ProviderRollbackConversationInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, numTurns: NonNegativeInt, }); @@ -95,7 +95,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record const directory = yield* ProviderSessionDirectory; const runtimeEventQueue = yield* Queue.unbounded(); const runtimeEventPubSub = yield* PubSub.unbounded(); - const routedSessionAliasesRef = yield* Ref.make>( - new Map(), - ); - - const canonicalizeRuntimeEventSession = ( - event: ProviderRuntimeEvent, - ): Effect.Effect => - Ref.get(routedSessionAliasesRef).pipe( - Effect.map((aliases) => { - for (const [staleSessionId, liveSessionId] of aliases) { - if (liveSessionId === event.sessionId) { - return { - ...event, - sessionId: staleSessionId, - } satisfies ProviderRuntimeEvent; - } - } - return event; - }), - ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - canonicalizeRuntimeEventSession(event).pipe( + Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => canonicalEventLogger - ? Effect.flatMap( - Effect.catch( - directory.getThreadId(canonicalEvent.sessionId).pipe( - Effect.map((threadIdOption) => - Option.isSome(threadIdOption) ? threadIdOption.value : null, - ), - ), - () => Effect.succeed(null), - ), - (orchestrationThreadId) => - canonicalEventLogger.write(canonicalEvent, orchestrationThreadId), - ) + ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), @@ -165,64 +134,15 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, - operation: string, threadId: ThreadId, ) => - Effect.gen(function* () { - const providerThreadId = session.threadId; - if (!providerThreadId) { - return yield* toValidationError( - operation, - `Provider '${session.provider}' returned a session without threadId.`, - ); - } - - yield* directory.upsert({ - sessionId: session.sessionId, - provider: session.provider, - threadId, - providerThreadId, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session), - }); - - return providerThreadId; - }); - - const clearAliasKey = (staleSessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - if (!current.has(staleSessionId)) { - return current; - } - const next = new Map(current); - next.delete(staleSessionId); - return next; - }); - - const clearAliasesReferencing = (sessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - let changed = false; - const next = new Map(); - for (const [key, value] of current) { - if (key === sessionId || value === sessionId) { - changed = true; - continue; - } - next.set(key, value); - } - return changed ? next : current; - }); - - const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - const existing = current.get(staleSessionId); - if (existing === liveSessionId) { - return current; - } - const next = new Map(current); - next.set(staleSessionId, liveSessionId); - return next; + directory.upsert({ + threadId, + provider: session.provider, + runtimeMode: session.runtimeMode, + status: toRuntimeStatus(session), + ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), + runtimePayload: toRuntimePayloadFromSession(session), }); const providers = yield* registry.listProviders(); @@ -245,163 +165,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ).pipe(Effect.asVoid); const recoverSessionForThread = (input: { - readonly staleSessionId: ProviderSessionId; - readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId }; + readonly binding: ProviderRuntimeBinding; readonly operation: string; }) => Effect.gen(function* () { const adapter = yield* registry.getByProvider(input.binding.provider); - const activeSessions = yield* adapter.listSessions(); - const resumeThreadId = input.binding.providerThreadId ?? undefined; const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; - const existing = - resumeThreadId === undefined - ? undefined - : activeSessions.find((session) => session.threadId === resumeThreadId); - if (existing) { - const existingProviderThreadId = yield* upsertSessionBinding( - existing, - `${input.operation}:upsertExistingSession`, - input.binding.threadId, - ); - yield* directory.upsert({ - sessionId: input.staleSessionId, - provider: existing.provider, - threadId: input.binding.threadId, - providerThreadId: existingProviderThreadId, - ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}), - }); - if (existing.sessionId !== input.staleSessionId) { - yield* setAlias(input.staleSessionId, existing.sessionId); - } else { - yield* clearAliasKey(input.staleSessionId); + const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); + if (hasActiveSession) { + const activeSessions = yield* adapter.listSessions(); + const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); + if (existing) { + yield* upsertSessionBinding(existing, input.binding.threadId); + return { adapter, session: existing } as const; } - return { - adapter, - sessionId: existing.sessionId, - } as const; } - if (!resumeThreadId && !hasResumeCursor) { + if (!hasResumeCursor) { return yield* toValidationError( input.operation, - `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, + `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, ); } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ + threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(resumeThreadId ? { resumeThreadId } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { return yield* toValidationError( input.operation, - `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, + `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, ); } - const resumedProviderThreadId = yield* upsertSessionBinding( - resumed, - `${input.operation}:upsertRecoveredSession`, - input.binding.threadId, - ); - - yield* directory.upsert({ - sessionId: input.staleSessionId, - provider: resumed.provider, - threadId: input.binding.threadId, - providerThreadId: resumedProviderThreadId, - ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}), - }); - - if (resumed.sessionId !== input.staleSessionId) { - yield* setAlias(input.staleSessionId, resumed.sessionId); - } else { - yield* clearAliasKey(input.staleSessionId); - } - - return { - adapter, - sessionId: resumed.sessionId, - } as const; + yield* upsertSessionBinding(resumed, input.binding.threadId); + return { adapter, session: resumed } as const; }); const resolveRoutableSession = (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly operation: string; readonly allowRecovery: boolean; }) => Effect.gen(function* () { - const bindingOption = yield* directory.getBinding(input.sessionId); + const bindingOption = yield* directory.getBinding(input.threadId); const binding = Option.getOrUndefined(bindingOption); if (!binding) { - return yield* new ProviderSessionNotFoundError({ - sessionId: input.sessionId, - }); - } - if (!binding.threadId) { return yield* toValidationError( input.operation, - `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`, + `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, ); } - const bindingWithThreadId: ProviderSessionBinding & { - readonly threadId: ThreadId; - } = { - ...binding, - threadId: binding.threadId, - }; const adapter = yield* registry.getByProvider(binding.provider); - const hasRequestedSession = yield* adapter.hasSession(input.sessionId); + const hasRequestedSession = yield* adapter.hasSession(input.threadId); if (hasRequestedSession) { - yield* clearAliasKey(input.sessionId); - return { - adapter, - sessionId: input.sessionId, - isActive: true, - } as const; - } - - const alias = yield* Ref.get(routedSessionAliasesRef).pipe( - Effect.map((aliases) => aliases.get(input.sessionId)), - ); - if (alias) { - const aliasIsActive = yield* adapter.hasSession(alias); - if (aliasIsActive) { - return { - adapter, - sessionId: alias, - isActive: true, - } as const; - } - yield* clearAliasKey(input.sessionId); + return { adapter, threadId: input.threadId, isActive: true } as const; } if (!input.allowRecovery) { - return { - adapter, - sessionId: input.sessionId, - isActive: false, - } as const; + return { adapter, threadId: input.threadId, isActive: false } as const; } - const recovered = yield* recoverSessionForThread({ - staleSessionId: input.sessionId, - binding: bindingWithThreadId, - operation: input.operation, - }); - - return { - adapter: recovered.adapter, - sessionId: recovered.sessionId, - isActive: true, - } as const; + const recovered = yield* recoverSessionForThread({ binding, operation: input.operation }); + return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; }); const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) => @@ -414,9 +248,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const input = { ...parsed, + threadId, provider: parsed.provider ?? "codex", - approvalPolicy: parsed.approvalPolicy ?? "never", - sandboxMode: parsed.sandboxMode ?? "workspace-write", }; const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession(input); @@ -428,7 +261,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding(session, "ProviderService.startSession", threadId); + yield* upsertSessionBinding(session, threadId); return session; }); @@ -452,28 +285,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.sendTurn", allowRecovery: true, }); - const turn = yield* routed.adapter.sendTurn({ - ...input, - sessionId: routed.sessionId, - }); - const threadId = yield* directory - .getThreadId(input.sessionId) - .pipe(Effect.map(Option.getOrUndefined)); - if (!threadId) { - return yield* toValidationError( - "ProviderService.sendTurn", - `No thread id is tracked for provider session '${input.sessionId}'.`, - ); - } + const turn = yield* routed.adapter.sendTurn(input); yield* directory.upsert({ - sessionId: input.sessionId, + threadId: input.threadId, provider: routed.adapter.provider, - threadId, - providerThreadId: turn.threadId, status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { @@ -493,11 +312,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.interruptTurn", allowRecovery: true, }); - yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId); + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); }); const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => @@ -508,11 +327,26 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.respondToRequest", allowRecovery: true, }); - yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision); + yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); + }); + + const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) => + Effect.gen(function* () { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.respondToUserInput", + schema: ProviderRespondToUserInputInput, + payload: rawInput, + }); + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.respondToUserInput", + allowRecovery: true, + }); + yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); }); const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => @@ -523,25 +357,61 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.stopSession", allowRecovery: false, }); if (routed.isActive) { - yield* routed.adapter.stopSession(routed.sessionId); - } - if (routed.sessionId !== input.sessionId) { - yield* directory.remove(routed.sessionId); - yield* clearAliasesReferencing(routed.sessionId); + yield* routed.adapter.stopSession(routed.threadId); } - yield* directory.remove(input.sessionId); - yield* clearAliasesReferencing(input.sessionId); + yield* directory.remove(input.threadId); }); const listSessions: ProviderServiceShape["listSessions"] = () => - Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( - Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), - ); + Effect.gen(function* () { + const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); + const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); + const persistedBindings = yield* directory + .listThreadIds() + .pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + directory.getBinding(threadId).pipe( + Effect.orElseSucceed(() => Option.none()), + ), + { concurrency: "unbounded" }, + ), + ), + Effect.orElseSucceed(() => [] as Array>), + ); + const bindingsByThreadId = new Map(); + for (const bindingOption of persistedBindings) { + const binding = Option.getOrUndefined(bindingOption); + if (binding) { + bindingsByThreadId.set(binding.threadId, binding); + } + } + + return activeSessions.map((session) => { + const binding = bindingsByThreadId.get(session.threadId); + if (!binding) { + return session; + } + + return { + ...session, + ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined + ? { resumeCursor: binding.resumeCursor } + : {}), + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + }; + }); + }); + + const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => + registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => Effect.gen(function* () { @@ -554,22 +424,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return; } const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.rollbackConversation", allowRecovery: true, }); - yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); + yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); }); const stopAll: ProviderServiceShape["stopAll"] = () => Effect.gen(function* () { - const sessionIds = yield* directory.listSessionIds(); + const threadIds = yield* directory.listThreadIds(); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); - yield* Effect.forEach(sessionIds, (sessionId) => - directory.getProvider(sessionId).pipe( + yield* Effect.forEach(threadIds, (threadId) => + directory.getProvider(threadId).pipe( Effect.flatMap((provider) => directory.upsert({ - sessionId, + threadId, provider, status: "stopped", runtimePayload: { @@ -581,9 +451,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ), ).pipe(Effect.asVoid); - // Keep persisted session bindings so stale sessions can be resumed after - // process restart via providerThreadId. - yield* Ref.set(routedSessionAliasesRef, new Map()); }); return { @@ -591,8 +458,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => sendTurn, interruptTurn, respondToRequest, + respondToUserInput, stopSession, listSessions, + getCapabilities, rollbackConversation, stopAll, streamEvents: Stream.fromPubSub(runtimeEventPubSub), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 471b38a548..22d4155622 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertFailure, assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; @@ -15,7 +15,7 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -31,74 +31,59 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts, reads, and removes session bindings", () => + it("upserts, reads, and removes thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const sessionId = ProviderSessionId.makeUnsafe("sess-1"); const initialThreadId = ThreadId.makeUnsafe("thread-1"); yield* directory.upsert({ - sessionId, provider: "codex", threadId: initialThreadId, }); - const provider = yield* directory.getProvider(sessionId); + const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); - const resolvedThreadId = yield* directory.getThreadId(sessionId); - assertSome(resolvedThreadId, initialThreadId); + const resolvedBinding = yield* directory.getBinding(initialThreadId); + assertSome(resolvedBinding, { + threadId: initialThreadId, + provider: "codex", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, initialThreadId); + } const nextThreadId = ThreadId.makeUnsafe("thread-2"); yield* directory.upsert({ - sessionId: sessionId, provider: "codex", threadId: nextThreadId, }); - const updatedThreadId = yield* directory.getThreadId(sessionId); - assertSome(updatedThreadId, nextThreadId); - const updatedBinding = yield* directory.getBinding(sessionId); + const updatedBinding = yield* directory.getBinding(nextThreadId); assert.equal(Option.isSome(updatedBinding), true); if (Option.isSome(updatedBinding)) { assert.equal(updatedBinding.value.threadId, nextThreadId); - assert.equal(updatedBinding.value.providerThreadId, null); } - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); + const runtime = yield* runtimeRepository.getByThreadId({ threadId: nextThreadId }); assert.equal(Option.isSome(runtime), true); if (Option.isSome(runtime)) { assert.equal(runtime.value.threadId, nextThreadId); - assert.equal(runtime.value.providerThreadId, null); assert.equal(runtime.value.status, "running"); - assert.equal(runtime.value.adapterKey, "codex"); + assert.equal(runtime.value.providerName, "codex"); } - const sessionIds = yield* directory.listSessionIds(); - assert.deepEqual(sessionIds, [sessionId]); - - yield* directory.remove(sessionId); - const missingProvider = yield* directory.getProvider(sessionId).pipe(Effect.result); - assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); - })); + const threadIds = yield* directory.listThreadIds(); + assert.deepEqual(threadIds, [nextThreadId]); - it("fails upsert when thread id is unavailable", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const result = yield* Effect.result( - directory.upsert({ - sessionId: ProviderSessionId.makeUnsafe("sess-no-thread"), - provider: "codex", - }), - ); + yield* directory.remove(nextThreadId); + const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); assertFailure( - result, - new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", + missingProvider, + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getProvider", + detail: `No persisted provider binding found for thread '${nextThreadId}'.`, }), ); })); @@ -108,18 +93,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const sessionId = ProviderSessionId.makeUnsafe("sess-runtime"); const threadId = ThreadId.makeUnsafe("thread-runtime"); - const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime"); yield* directory.upsert({ - sessionId, provider: "codex", threadId, - providerThreadId, status: "starting", resumeCursor: { - resumeThreadId: "provider-thread-runtime", + threadId: "provider-thread-runtime", }, runtimePayload: { cwd: "/tmp/project", @@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }); yield* directory.upsert({ - sessionId, provider: "codex", + threadId, status: "running", runtimePayload: { activeTurnId: "turn-1", }, }); - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); assert.equal(Option.isSome(runtime), true); if (Option.isSome(runtime)) { assert.equal(runtime.value.threadId, threadId); - assert.equal(runtime.value.providerThreadId, providerThreadId); assert.equal(runtime.value.status, "running"); assert.deepEqual(runtime.value.resumeCursor, { - resumeThreadId: providerThreadId, + threadId: "provider-thread-runtime", }); assert.deepEqual(runtime.value.runtimePayload, { cwd: "/tmp/project", @@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); - it("clears providerThreadId when explicitly set to null", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; - - const sessionId = ProviderSessionId.makeUnsafe("sess-clear-provider-thread-id"); - const threadId = ThreadId.makeUnsafe("thread-clear-provider-thread-id"); - const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-to-clear"); - - yield* directory.upsert({ - sessionId, - provider: "codex", - threadId, - adapterKey: "custom-adapter", - providerThreadId, - }); - - yield* directory.upsert({ - sessionId, - provider: "codex", - providerThreadId: null, - }); - - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerThreadId, null); - assert.equal(runtime.value.adapterKey, "custom-adapter"); - } - })); - it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); - const sessionId = ProviderSessionId.makeUnsafe("sess-restart"); const threadId = ThreadId.makeUnsafe("thread-restart"); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId, provider: "codex", threadId, }); @@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const sql = yield* SqlClient.SqlClient; - const provider = yield* directory.getProvider(sessionId); + const provider = yield* directory.getProvider(threadId); assert.equal(provider, "codex"); - const resolvedThreadId = yield* directory.getThreadId(sessionId); - assertSome(resolvedThreadId, threadId); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "codex", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } const legacyTableRows = yield* sql<{ readonly name: string }>` SELECT name @@ -225,4 +174,26 @@ 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); + } + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 1b096e91e0..a45cd87dec 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,15 +1,14 @@ -import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, - ProviderSessionNotFoundError, ProviderValidationError, } from "../Errors.ts"; import { ProviderSessionDirectory, - type ProviderSessionBinding, + type ProviderRuntimeBinding, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; @@ -26,7 +25,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeCode") { + if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { return Effect.succeed(providerName); } return Effect.fail( @@ -49,10 +48,7 @@ function mergeRuntimePayload( return existing ?? null; } if (isRecord(existing) && isRecord(next)) { - return { - ...existing, - ...next, - }; + return { ...existing, ...next }; } return next; } @@ -60,21 +56,20 @@ function mergeRuntimePayload( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - const getBinding = (sessionId: ProviderSessionId) => - repository.getBySessionId({ providerSessionId: sessionId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), + const getBinding = (threadId: ThreadId) => + repository.getByThreadId({ threadId }).pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), Effect.flatMap((runtime) => Option.match(runtime, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeed(Option.none()), onSome: (value) => decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( Effect.map((provider) => Option.some({ - sessionId: value.providerSessionId, - provider, threadId: value.threadId, + provider, adapterKey: value.adapterKey, - providerThreadId: value.providerThreadId, + runtimeMode: value.runtimeMode, status: value.status, resumeCursor: value.resumeCursor, runtimePayload: value.runtimePayload, @@ -87,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { const existing = yield* repository - .getBySessionId({ - providerSessionId: binding.sessionId, - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId"))); + .getByThreadId({ threadId: binding.threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); const existingRuntime = Option.getOrUndefined(existing); const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; @@ -104,14 +97,10 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const now = new Date().toISOString(); yield* repository .upsert({ - providerSessionId: binding.sessionId, threadId: resolvedThreadId, providerName: binding.provider, adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, - providerThreadId: - binding.providerThreadId !== undefined - ? binding.providerThreadId - : (existingRuntime?.providerThreadId ?? null), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", status: binding.status ?? existingRuntime?.status ?? "running", lastSeenAt: now, resumeCursor: @@ -126,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () { .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); - const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => - getBinding(sessionId).pipe( + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => + getBinding(threadId).pipe( Effect.flatMap((binding) => Option.match(binding, { onSome: (value) => Effect.succeed(value.provider), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - }), - ), - ); - - const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) => - getBinding(sessionId); - - const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => - getBinding(sessionId).pipe( - Effect.flatMap((binding) => - Option.match(binding, { - onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + onNone: () => + Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getProvider", + detail: `No persisted provider binding found for thread '${threadId}'.`, + }), + ), }), ), ); - const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => + const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => repository - .deleteBySessionId({ providerSessionId: sessionId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), - ); + .deleteByThreadId({ threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId"))); - const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( - Effect.map((provider) => ({ - sessionId: row.providerSessionId, - provider, - threadId: row.threadId, - })), - ), - { concurrency: "unbounded" }, - ), - ), - Effect.map((bindings) => bindings.map((binding) => binding.sessionId)), + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), + Effect.map((rows) => rows.map((row) => row.threadId)), ); return { upsert, getProvider, - getBinding: getBindingBySessionId, - getThreadId, + getBinding, remove, - listSessionIds, + listThreadIds, } satisfies ProviderSessionDirectoryShape; }); 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/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index bb51ff8377..67755b5383 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -11,25 +11,34 @@ import type { ApprovalRequestId, ProviderApprovalDecision, ProviderKind, + ProviderUserInputAnswers, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, - ProviderSessionId, ProviderSessionStartInput, - ProviderThreadId, - ProviderTurnId, + ThreadId, ProviderTurnStartResult, + TurnId, } from "@t3tools/contracts"; import type { Effect } from "effect"; import type { Stream } from "effect"; +export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported"; + +export interface ProviderAdapterCapabilities { + /** + * Declares whether changing the model on an existing session is supported. + */ + readonly sessionModelSwitch: ProviderSessionModelSwitchMode; +} + export interface ProviderThreadTurnSnapshot { - readonly id: ProviderTurnId; + readonly id: TurnId; readonly items: ReadonlyArray; } export interface ProviderThreadSnapshot { - readonly threadId: ProviderThreadId; + readonly threadId: ThreadId; readonly turns: ReadonlyArray; } @@ -38,6 +47,7 @@ export interface ProviderAdapterShape { * Provider kind implemented by this adapter. */ readonly provider: ProviderKind; + readonly capabilities: ProviderAdapterCapabilities; /** * Start a provider-backed session. @@ -57,23 +67,32 @@ export interface ProviderAdapterShape { * Interrupt an active turn. */ readonly interruptTurn: ( - sessionId: ProviderSessionId, - turnId?: ProviderTurnId, + threadId: ThreadId, + turnId?: TurnId, ) => Effect.Effect; /** * Respond to an interactive approval request. */ readonly respondToRequest: ( - sessionId: ProviderSessionId, + threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Effect.Effect; + /** + * Respond to a structured user-input request. + */ + readonly respondToUserInput: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ) => Effect.Effect; + /** * Stop one provider session. */ - readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect; + readonly stopSession: (threadId: ThreadId) => Effect.Effect; /** * List currently active provider sessions for this adapter. @@ -83,20 +102,20 @@ export interface ProviderAdapterShape { /** * Check whether this adapter owns an active session id. */ - readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect; + readonly hasSession: (threadId: ThreadId) => Effect.Effect; /** * Read a provider thread snapshot. */ readonly readThread: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; /** * Roll back a provider thread by N turns. */ readonly rollbackThread: ( - sessionId: ProviderSessionId, + threadId: ThreadId, numTurns: number, ) => Effect.Effect; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 656d25a9a7..600541e895 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -13,11 +13,12 @@ */ import type { ProviderInterruptTurnInput, + ProviderKind, ProviderRespondToRequestInput, + ProviderRespondToUserInputInput, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, - ProviderSessionId, ProviderSessionStartInput, ProviderStopSessionInput, ThreadId, @@ -27,6 +28,7 @@ import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; import type { ProviderServiceError } from "../Errors.ts"; +import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; /** * ProviderServiceShape - Service API for provider session and turn orchestration. @@ -61,6 +63,13 @@ export interface ProviderServiceShape { input: ProviderRespondToRequestInput, ) => Effect.Effect; + /** + * Respond to a provider structured user-input request. + */ + readonly respondToUserInput: ( + input: ProviderRespondToUserInputInput, + ) => Effect.Effect; + /** * Stop a provider session. */ @@ -75,11 +84,18 @@ export interface ProviderServiceShape { */ readonly listSessions: () => Effect.Effect>; + /** + * Read static capabilities for a provider adapter. + */ + readonly getCapabilities: ( + provider: ProviderKind, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ readonly rollbackConversation: (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly numTurns: number; }) => Effect.Effect; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 67628dfa33..3a374976b0 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,17 +1,7 @@ -/** - * ProviderSessionDirectory - Session ownership index across provider adapters. - * - * Tracks which provider owns each `sessionId` so `ProviderService` can route - * session-scoped calls to the correct adapter. It is metadata only and does not - * perform provider RPC or checkpoint operations. - * - * @module ProviderSessionDirectory - */ import type { ProviderKind, - ProviderSessionId, ProviderSessionRuntimeStatus, - ProviderThreadId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { Option, ServiceMap } from "effect"; @@ -19,83 +9,48 @@ import type { Effect } from "effect"; import type { ProviderSessionDirectoryPersistenceError, - ProviderSessionNotFoundError, ProviderValidationError, } from "../Errors.ts"; -export interface ProviderSessionBinding { - readonly sessionId: ProviderSessionId; +export interface ProviderRuntimeBinding { + readonly threadId: ThreadId; readonly provider: ProviderKind; - readonly threadId?: ThreadId; readonly adapterKey?: string; - readonly providerThreadId?: ProviderThreadId | null; readonly status?: ProviderSessionRuntimeStatus; readonly resumeCursor?: unknown | null; readonly runtimePayload?: unknown | null; + readonly runtimeMode?: RuntimeMode; } -export type ProviderSessionDirectoryReadError = - | ProviderSessionNotFoundError - | ProviderSessionDirectoryPersistenceError; +export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; -/** - * ProviderSessionDirectoryShape - Service API for provider session ownership metadata. - */ export interface ProviderSessionDirectoryShape { - /** - * Record or update ownership for one provider session. - * - * Preserves existing persisted fields when omitted and shallow-merges - * runtime payload objects. - */ readonly upsert: ( - binding: ProviderSessionBinding, + binding: ProviderRuntimeBinding, ) => Effect.Effect; - /** - * Resolve the provider owner for a session id. - */ readonly getProvider: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; - /** - * Resolve the full tracked binding for a session id. - */ readonly getBinding: ( - sessionId: ProviderSessionId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; - - /** - * Resolve the tracked thread id for a session, if known. - */ - readonly getThreadId: ( - sessionId: ProviderSessionId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; + threadId: ThreadId, + ) => Effect.Effect, ProviderSessionDirectoryReadError>; - /** - * Remove a session binding. - */ readonly remove: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; - /** - * List tracked session ids. - */ - readonly listSessionIds: () => Effect.Effect< - ReadonlyArray, + readonly listThreadIds: () => Effect.Effect< + ReadonlyArray, ProviderSessionDirectoryPersistenceError >; } -/** - * ProviderSessionDirectory - Service tag for session ownership lookup. - */ export class ProviderSessionDirectory extends ServiceMap.Service< ProviderSessionDirectory, ProviderSessionDirectoryShape diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index cc4f139577..32dd79fea6 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -18,6 +18,7 @@ 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 { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -56,8 +57,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts new file mode 100644 index 0000000000..0abf9f3826 --- /dev/null +++ b/apps/server/src/serverLogger.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; + +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* Effect.serviceOption(ServerConfig); + if (Option.isNone(config)) { + return Logger.layer([Logger.defaultLogger]); + } + + const logDir = path.join(config.value.stateDir, "logs"); + const logPath = path.join(logDir, "server.log"); + + yield* Effect.sync(() => { + fs.mkdirSync(logDir, { recursive: true }); + }); + + const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath)); + + return Logger.layer([Logger.defaultLogger, fileLogger], { + mergeWithExisting: false, + }); +}).pipe(Layer.unwrap); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e0dc4a079d..50e2561c59 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -18,9 +18,8 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, + ThreadId, + TurnId, WS_CHANNELS, WS_METHODS, type WebSocketResponse, @@ -60,11 +59,9 @@ interface PendingMessages { const pendingBySocket = new WeakMap(); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const defaultOpenService: OpenShape = { openBrowser: () => Effect.void, @@ -1129,6 +1126,8 @@ describe("WebSocket Server", () => { projectId: "project-diff", title: "Diff Thread", model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", branch: null, worktreePath: null, createdAt, @@ -1146,30 +1145,31 @@ describe("WebSocket Server", () => { it("keeps orchestration domain push behavior for provider runtime events", async () => { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const sessionId = asProviderSessionId("sess-test"); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const providerService: ProviderServiceShape = { - startSession: () => + startSession: (threadId) => Effect.succeed({ - sessionId, provider: "codex", status: "ready", - threadId: asProviderThreadId("provider-thread-1"), + runtimeMode: "full-access", + threadId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), - sendTurn: () => + sendTurn: ({ threadId }) => Effect.succeed({ - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("provider-turn-1"), + threadId, + turnId: asTurnId("provider-turn-1"), }), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -1205,6 +1205,8 @@ describe("WebSocket Server", () => { projectId: "project-1", title: "Thread 1", model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", branch: null, worktreePath: null, createdAt, @@ -1222,8 +1224,8 @@ describe("WebSocket Server", () => { attachments: [], }, assistantDeliveryMode: "streaming", - approvalPolicy: "on-request", - sandboxMode: "workspace-write", + runtimeMode: "approval-required", + interactionMode: "default", createdAt, }); expect(startTurnResponse.error).toBeUndefined(); @@ -1233,16 +1235,21 @@ describe("WebSocket Server", () => { return event.type === "thread.session-set"; }); - emitRuntimeEvent({ - type: "message.delta", - eventId: asEventId("evt-ws-runtime-message-delta"), - provider: "codex", - sessionId, - createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-1"), - itemId: asProviderItemId("item-1"), - delta: "hello from runtime", - }); + emitRuntimeEvent( + { + type: "content.delta", + eventId: asEventId("evt-ws-runtime-message-delta"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-1"), + itemId: asProviderItemId("item-1"), + payload: { + streamKind: "assistant_text", + delta: "hello from runtime", + }, + } as unknown as ProviderRuntimeEvent, + ); const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; @@ -1518,6 +1525,54 @@ describe("WebSocket Server", () => { }); }); + it("supports projects.writeFile within the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n\n- step 1\n", + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + relativePath: "plans/effect-rpc.md", + }); + expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( + "# Plan\n\n- step 1\n", + ); + }); + + it("rejects projects.writeFile paths outside the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-reject-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "../escape.md", + contents: "# no\n", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Workspace file path must stay within the project root."); + expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); + }); + it("routes git core methods over websocket", async () => { const listBranches = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 50af3b8b10..cd5e842732 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,6 +12,7 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, @@ -146,6 +147,48 @@ function websocketRawToString(raw: unknown): string | null { return null; } +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function resolveWorkspaceWritePath(params: { + workspaceRoot: string; + relativePath: string; + path: Path.Path; +}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { + const normalizedInputPath = params.relativePath.trim(); + if (params.path.isAbsolute(normalizedInputPath)) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must be relative to the project root.", + }), + ); + } + + const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath( + params.path.relative(params.workspaceRoot, absolutePath), + ); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + params.path.isAbsolute(relativeToRoot) + ) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must stay within the project root.", + }), + ); + } + + return Effect.succeed({ + absolutePath, + relativePath: relativeToRoot, + }); +} + function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } @@ -595,6 +638,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< projectId: bootstrapProjectId, title: "New thread", model: bootstrapProjectDefaultModel, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -612,6 +657,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); } + const runPromise = yield* Effect.map(Effect.services(), Effect.runPromiseWith); + 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)), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); + + yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( + Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), + ); + + yield* Effect.addFinalizer(() => + Effect.all([ + closeAllClients, + closeWebSocketServer.pipe( + Effect.catch((error) => + Effect.logWarning("failed to close web socket server", { cause: error }), + ), + ), + ]), + ); + const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { switch (request.body._tag) { case ORCHESTRATION_WS_METHODS.getSnapshot: @@ -656,6 +728,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } + case WS_METHODS.projectsWriteFile: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to prepare workspace path: ${String(cause)}`, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to write workspace file: ${String(cause)}`, + }), + ), + ); + return { relativePath: target.relativePath }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); @@ -799,6 +897,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< id: request.value.id, result: result.value, }); + ws.send(response); }); @@ -827,7 +926,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); wss.on("connection", (ws) => { - void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws))); + void runPromise(Ref.update(clients, (clients) => clients.add(ws))); const segments = cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project"; @@ -846,7 +945,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ws.send(JSON.stringify(welcome)); ws.on("message", (raw) => { - void Effect.runPromise( + void runPromise( handleMessage(ws, raw).pipe( Effect.catch((error) => Effect.logError("Error handling message", error)), ), @@ -854,7 +953,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); ws.on("close", () => { - void Effect.runPromise( + void runPromise( Ref.update(clients, (clients) => { clients.delete(ws); return clients; @@ -863,7 +962,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); ws.on("error", () => { - void Effect.runPromise( + void runPromise( Ref.update(clients, (clients) => { clients.delete(ws); return clients; @@ -872,32 +971,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); - 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)), - ); - yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); - - yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( - Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), - ); - - yield* Effect.addFinalizer(() => - Effect.all([ - closeAllClients, - closeWebSocketServer.pipe( - Effect.catch((error) => - Effect.logWarning("failed to close web socket server", { cause: error }), - ), - ), - ]), - ); - return httpServer; }); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index b5dd31d308..5d5509ce10 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -15,13 +15,25 @@ 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", + ]); + }); }); describe("getAppModelOptions", () => { it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions(["custom/internal-model"]); + const options = getAppModelOptions("codex", ["custom/internal-model"]); expect(options.map((option) => option.slug)).toEqual([ + "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2-codex", @@ -31,7 +43,7 @@ describe("getAppModelOptions", () => { }); it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions([], "custom/selected-model"); + const options = getAppModelOptions("codex", [], "custom/selected-model"); expect(options.at(-1)).toEqual({ slug: "custom/selected-model", @@ -39,18 +51,44 @@ 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("getSlashModelOptions", () => { it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex"); + const options = getSlashModelOptions( + "codex", + ["custom/internal-model"], + "", + "gpt-5.3-codex", + ); expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); }); it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); + const options = getSlashModelOptions( + "codex", + ["openai/gpt-oss-120b"], + "oss", + "gpt-5.3-codex", + ); 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); + }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 2977b7fb3d..fa09a909f8 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,11 +1,16 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; +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; -const BUILT_IN_MODEL_SLUGS = new Set(MODEL_OPTIONS.map((option) => option.slug)); +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)), +}; const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( @@ -21,6 +26,12 @@ const AppSettingsSchema = Schema.Struct({ 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([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -37,16 +48,18 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; export function normalizeCustomModelSlugs( models: Iterable, -): AppSettings["customCodexModels"] { + provider: ProviderKind = "codex", +): string[] { const normalizedModels: string[] = []; const seen = new Set(); + const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { - const normalized = normalizeModelSlug(candidate); + const normalized = normalizeModelSlug(candidate, provider); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || - BUILT_IN_MODEL_SLUGS.has(normalized) || + builtInModelSlugs.has(normalized) || seen.has(normalized) ) { continue; @@ -65,22 +78,25 @@ export function normalizeCustomModelSlugs( function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels), + customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), + customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), }; } export function getAppModelOptions( + provider: ProviderKind, customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({ + const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ slug, name, isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); - for (const slug of normalizeCustomModelSlugs(customModels)) { + for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { continue; } @@ -93,7 +109,7 @@ export function getAppModelOptions( }); } - const normalizedSelectedModel = normalizeModelSlug(selectedModel); + const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { options.push({ slug: normalizedSelectedModel, @@ -106,12 +122,13 @@ export function getAppModelOptions( } export function getSlashModelOptions( + provider: ProviderKind, customModels: readonly string[], query: string, selectedModel?: string | null, ): AppModelOption[] { const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(customModels, selectedModel); + const options = getAppModelOptions(provider, customModels, selectedModel); if (!normalizedQuery) { return options; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1859d125a1..d1c68d321d 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -52,8 +52,7 @@ export default function BranchToolbar({ const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. - const sessionId = serverThread?.session?.sessionId; - if (sessionId && worktreePath !== activeWorktreePath && api) { + if (serverThread?.session && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -89,7 +88,7 @@ export default function BranchToolbar({ }, [ activeThreadId, - serverThread?.session?.sessionId, + serverThread?.session, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d3f3ede966..7ae275480b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -6,7 +6,6 @@ import { type MessageId, type OrchestrationReadModel, type ProjectId, - type ProviderSessionId, type ServerConfig, type ThreadId, type WsWelcomePayload, @@ -204,6 +203,8 @@ function createSnapshotForTargetUser(options: { projectId: PROJECT_ID, title: "Browser test thread", model: "gpt-5", + interactionMode: "default", + runtimeMode: "full-access", branch: "main", worktreePath: null, latestTurn: null, @@ -212,15 +213,13 @@ function createSnapshotForTargetUser(options: { deletedAt: null, messages, activities: [], + proposedPlans: [], checkpoints: [], session: { threadId: THREAD_ID, status: "ready", providerName: "codex", - providerSessionId: "session-1" as ProviderSessionId, - providerThreadId: null, - approvalPolicy: "on-failure", - sandboxMode: "workspace-write", + runtimeMode: "full-access", activeTurnId: null, lastError: null, updatedAt: NOW_ISO, @@ -552,7 +551,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projects: [], threads: [], threadsHydrated: false, - runtimeMode: "full-access", }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 494e14be75..c09bbb03d7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,28 +1,50 @@ import { type ApprovalRequestId, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL, - DEFAULT_REASONING, EDITORS, type EditorId, type KeybindingCommand, + type CodexReasoningEffort, + type CursorReasoningOption, type MessageId, type ProjectId, type ProjectEntry, type ProjectScript, + type ModelSlug, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - REASONING_OPTIONS, - type ReasoningEffort, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ServerProviderStatus, + type ProviderKind, type ThreadId, type TurnId, - normalizeModelSlug, - resolveModelSlug, OrchestrationThreadActivity, + RuntimeMode, + ProviderInteractionMode, } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + getDefaultModel, + getDefaultReasoningEffort, + getCursorModelCapabilities, + getCursorModelFamilyOptions, + getReasoningEffortOptions, + normalizeModelSlug, + parseCursorModelSelection, + resolveCursorModelFromSelection, + resolveModelSlugForProvider, +} from "@t3tools/shared/model"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useId, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -38,17 +60,24 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { + type ComposerSlashCommand, type ComposerTrigger, type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, + parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, + derivePendingUserInputs, derivePhase, deriveTimelineEntries, + deriveActivePlanState, + findLatestProposedPlan, type PendingApproval, + type PendingUserInput, + PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, @@ -56,9 +85,23 @@ import { formatTimestamp, } from "../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; +import { + buildPendingUserInputAnswers, + derivePendingUserInputProgress, + setPendingUserInputCustomAnswer, + type PendingUserInputDraftAnswer, +} from "../pendingUserInput"; import { useStore } from "../store"; +import { + buildPlanImplementationThreadTitle, + buildPlanImplementationPrompt, + buildProposedPlanMarkdownFilename, + proposedPlanTitle, +} from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatMessage, @@ -93,6 +136,7 @@ import { FileIcon, FolderIcon, DiffIcon, + EllipsisIcon, FolderClosedIcon, InfoIcon, LockIcon, @@ -103,15 +147,47 @@ import { CheckIcon, } from "lucide-react"; import { Button } from "./ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Input } from "./ui/input"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { CursorIcon, Icon, VisualStudioCode, Zed } from "./Icons"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuShortcut, + MenuTrigger, +} from "./ui/menu"; +import { + ClaudeAI, + CursorIcon, + Gemini, + Icon, + OpenAI, + OpenCodeIcon, + VisualStudioCode, + Zed, +} from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandItem, CommandList } from "./ui/command"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { @@ -123,9 +199,9 @@ import { } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId } from "~/lib/utils"; +import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings"; +import { getAppModelOptions, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -157,6 +233,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -187,6 +264,22 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } +function normalizePlanMarkdownForExport(planMarkdown: string): string { + return `${planMarkdown.trimEnd()}\n`; +} + +function downloadTextFile(filename: string, contents: string): void { + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 0); +} + interface ExpandedImageItem { src: string; name: string; @@ -229,6 +322,8 @@ function buildLocalDraftThread( projectId: draftThread.projectId, title: "New thread", model: fallbackModel, + runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, session: null, messages: [], error, @@ -239,6 +334,7 @@ function buildLocalDraftThread( worktreePath: draftThread.worktreePath, turnDiffSummaries: [], activities: [], + proposedPlans: [], }; } @@ -286,13 +382,15 @@ type ComposerCommandItem = | { id: string; type: "slash-command"; + command: ComposerSlashCommand; label: string; description: string; } | { id: string; type: "model"; - model: string; + provider: ProviderKind; + model: ModelSlug; label: string; description: string; }; @@ -461,10 +559,9 @@ interface ChatViewProps { export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); - const runtimeMode = useStore((store) => store.runtimeMode); const markThreadVisited = useStore((store) => store.markThreadVisited); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); - const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const navigate = useNavigate(); @@ -480,8 +577,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + 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 setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); + const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -509,8 +610,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< + ApprovalRequestId[] + >([]); + const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< + Record> + >({}); + const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = + useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); @@ -533,6 +641,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastTouchClientYRef = useRef(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); + const pendingInteractionAnchorRef = useRef<{ + element: HTMLElement; + top: number; + } | null>(null); + const pendingInteractionAnchorFrameRef = useRef(null); const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); @@ -602,6 +715,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; + const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = + composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const diffSearch = useMemo( @@ -632,19 +748,119 @@ export default function ChatView({ threadId }: ChatViewProps) { markThreadVisited, ]); - const baseThreadModel = resolveModelSlug( - activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, + const sessionProvider = activeThread?.session?.provider ?? null; + const selectedProviderByThreadId = composerDraft.provider; + const hasThreadStarted = Boolean( + activeThread && + (activeThread.latestTurn !== null || + activeThread.messages.length > 0 || + activeThread.session !== null), + ); + 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) ?? + DEFAULT_MODEL, + ); + const selectedModel = useMemo(() => { + const draftModel = composerDraft.model; + if (!draftModel) { + return baseThreadModel; + } + + 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); + const selectedCodexFastModeEnabled = + selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const selectedModelOptionsForDispatch = useMemo(() => { + if (selectedProvider !== "codex") { + return undefined; + } + const codexOptions = { + ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + const selectedCursorModel = useMemo( + () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), + [selectedModel, selectedProvider], + ); + const selectedCursorModelCapabilities = useMemo( + () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null), + [selectedCursorModel], ); - const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); - const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; - const modelOptions = useMemo( - () => getAppModelOptions(settings.customCodexModels, selectedModel), - [selectedModel, settings.customCodexModels], + const hasSelectedCursorTraits = Boolean( + selectedCursorModelCapabilities && + (selectedCursorModelCapabilities.supportsReasoning || + selectedCursorModelCapabilities.supportsFast || + selectedCursorModelCapabilities.supportsThinking), ); - const slashModelOptions = useMemo( + const selectedModelForPicker = + selectedProvider === "cursor" && selectedCursorModel + ? selectedCursorModel.family + : selectedModel; + const modelOptionsByProvider = useMemo( + () => getCustomModelOptionsByProvider(settings), + [settings], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + 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 + : selectedModelForPicker; + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( () => - getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel), - [composerTrigger?.query, selectedModel, settings.customCodexModels], + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -664,6 +880,80 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingApprovals(threadActivities), [threadActivities], ); + const pendingUserInputs = useMemo( + () => derivePendingUserInputs(threadActivities), + [threadActivities], + ); + const activePendingUserInput = pendingUserInputs[0] ?? null; + const activePendingDraftAnswers = useMemo( + () => + activePendingUserInput + ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + EMPTY_PENDING_USER_INPUT_ANSWERS) + : EMPTY_PENDING_USER_INPUT_ANSWERS, + [activePendingUserInput, pendingUserInputAnswersByRequestId], + ); + const activePendingQuestionIndex = activePendingUserInput + ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + : 0; + const activePendingProgress = useMemo( + () => + activePendingUserInput + ? derivePendingUserInputProgress( + activePendingUserInput.questions, + activePendingDraftAnswers, + activePendingQuestionIndex, + ) + : null, + [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], + ); + const activePendingResolvedAnswers = useMemo( + () => + activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + : null, + [activePendingDraftAnswers, activePendingUserInput], + ); + const activePendingIsResponding = activePendingUserInput + ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) + : false; + const activeProposedPlan = useMemo(() => { + if (!latestTurnSettled) { + return null; + } + return findLatestProposedPlan( + activeThread?.proposedPlans ?? [], + activeLatestTurn?.turnId ?? null, + ); + }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + const activePlan = useMemo( + () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), + [activeLatestTurn?.turnId, threadActivities], + ); + const showPlanFollowUpPrompt = + pendingUserInputs.length === 0 && + interactionMode === "plan" && + latestTurnSettled && + activeProposedPlan !== null; + const hasComposerHeader = + pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); + useEffect(() => { + if (!activePendingProgress) { + return; + } + promptRef.current = activePendingProgress.customAnswer; + setComposerCursor(activePendingProgress.customAnswer.length); + setComposerTrigger( + detectComposerTrigger( + activePendingProgress.customAnswer, + expandCollapsedComposerCursor( + activePendingProgress.customAnswer, + activePendingProgress.customAnswer.length, + ), + ), + ); + setComposerHighlightedItemId(null); + }, [activePendingProgress, activePendingUserInput?.requestId]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -782,8 +1072,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( - () => deriveTimelineEntries(timelineMessages, workLogEntries), - [timelineMessages, workLogEntries], + () => + deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), + [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -908,27 +1199,55 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - if (!"model".includes(composerTrigger.query.toLowerCase())) { - return []; - } - return [ + const slashCommandItems = [ { id: "slash:model", type: "slash-command", + command: "model", label: "/model", description: "Switch response model for this thread", }, - ]; + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal chat mode", + }, + ] satisfies ReadonlyArray>; + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) { + return [...slashCommandItems]; + } + return slashCommandItems.filter( + (item) => item.command.includes(query) || item.label.slice(1).includes(query), + ); } - return slashModelOptions.map(({ slug, name }) => ({ - id: `model:${slug}`, - type: "model" as const, - model: slug, - label: name, - description: slug, - })); - }, [composerTrigger, slashModelOptions, workspaceEntries]); + return searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return ( + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); + }, [composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -1285,34 +1604,91 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeProject, persistProjectScripts], ); - const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { - if (mode === runtimeMode) return; - setStoreRuntimeMode(mode); - scheduleComposerFocus(); - const api = readNativeApi(); - if (!api) return; + const handleRuntimeModeChange = useCallback( + (mode: RuntimeMode) => { + if (mode === runtimeMode) return; + setComposerDraftRuntimeMode(threadId, mode); + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { runtimeMode: mode }); + } + scheduleComposerFocus(); + }, + [ + isLocalDraftThread, + runtimeMode, + scheduleComposerFocus, + setComposerDraftRuntimeMode, + setDraftThreadContext, + threadId, + ], + ); - const runningThreadIds = threads - .filter((thread) => thread.session !== null && thread.session.status !== "closed") - .map((thread) => thread.id); + const handleInteractionModeChange = useCallback( + (mode: ProviderInteractionMode) => { + if (mode === interactionMode) return; + setComposerDraftInteractionMode(threadId, mode); + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { interactionMode: mode }); + } + scheduleComposerFocus(); + }, + [ + interactionMode, + isLocalDraftThread, + scheduleComposerFocus, + setComposerDraftInteractionMode, + setDraftThreadContext, + threadId, + ], + ); - if (runningThreadIds.length === 0) return; + const persistThreadSettingsForNextTurn = useCallback( + async (input: { + threadId: ThreadId; + createdAt: string; + model?: string; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + }) => { + if (!serverThread) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } - setIsSwitchingRuntimeMode(true); - await Promise.all( - runningThreadIds.map((threadId) => - api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined), - ), - ); - setIsSwitchingRuntimeMode(false); - }; + if (input.model !== undefined && input.model !== serverThread.model) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadId, + model: input.model, + }); + } + + if (input.runtimeMode !== serverThread.runtimeMode) { + await api.orchestration.dispatchCommand({ + type: "thread.runtime-mode.set", + commandId: newCommandId(), + threadId: input.threadId, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + }); + } + + if (input.interactionMode !== serverThread.interactionMode) { + await api.orchestration.dispatchCommand({ + type: "thread.interaction-mode.set", + commandId: newCommandId(), + threadId: input.threadId, + interactionMode: input.interactionMode, + createdAt: input.createdAt, + }); + } + }, + [serverThread], + ); useEffect(() => { try { @@ -1344,6 +1720,12 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingAutoScrollFrameRef.current = null; window.cancelAnimationFrame(pendingFrame); }, []); + const cancelPendingInteractionAnchorAdjustment = useCallback(() => { + const pendingFrame = pendingInteractionAnchorFrameRef.current; + if (pendingFrame === null) return; + pendingInteractionAnchorFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); const scheduleStickToBottom = useCallback(() => { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { @@ -1351,6 +1733,40 @@ export default function ChatView({ threadId }: ChatViewProps) { scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); + const onMessagesClickCapture = useCallback( + (event: React.MouseEvent) => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer || !(event.target instanceof Element)) return; + + const trigger = event.target.closest( + "button, summary, [role='button'], [data-scroll-anchor-target]", + ); + if (!trigger || !scrollContainer.contains(trigger)) return; + + pendingInteractionAnchorRef.current = { + element: trigger, + top: trigger.getBoundingClientRect().top, + }; + + cancelPendingInteractionAnchorAdjustment(); + pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { + pendingInteractionAnchorFrameRef.current = null; + const anchor = pendingInteractionAnchorRef.current; + pendingInteractionAnchorRef.current = null; + const activeScrollContainer = messagesScrollRef.current; + if (!anchor || !activeScrollContainer) return; + if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; + + const nextTop = anchor.element.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) < 0.5) return; + + activeScrollContainer.scrollTop += delta; + lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; + }); + }, + [cancelPendingInteractionAnchorAdjustment], + ); const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom(); scrollMessagesToBottom(); @@ -1420,8 +1836,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { return () => { cancelPendingStickToBottom(); + cancelPendingInteractionAnchorAdjustment(); }; - }, [cancelPendingStickToBottom]); + }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); useLayoutEffect(() => { if (!activeThread?.id) return; shouldAutoScrollRef.current = true; @@ -1940,7 +2357,39 @@ export default function ChatView({ threadId }: ChatViewProps) { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (activePendingProgress) { + onAdvanceActivePendingUserInput(); + return; + } const trimmed = prompt.trim(); + if (showPlanFollowUpPrompt && activeProposedPlan) { + 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: followUpText, + interactionMode: nextInteractionMode, + }); + return; + } + const standaloneSlashCommand = + composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + if (standaloneSlashCommand) { + await handleInteractionModeChange(standaloneSlashCommand); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; @@ -2053,13 +2502,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncateTitle(titleSeed); - let threadCreateModel = selectedModel; - if (!threadCreateModel) { - threadCreateModel = activeProject.model; - } - if (!threadCreateModel) { - threadCreateModel = DEFAULT_MODEL; - } + let threadCreateModel: ModelSlug = + selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2069,6 +2513,8 @@ export default function ChatView({ threadId }: ChatViewProps) { projectId: activeProject.id, title, model: threadCreateModel, + runtimeMode, + interactionMode, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, createdAt: activeThread.createdAt, @@ -2112,10 +2558,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } + if (isServerThread) { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { model: selectedModel } : {}), + runtimeMode, + interactionMode, + }); + } + setSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; - const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; - const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2127,10 +2581,13 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, - effort: selectedEffort || undefined, + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), + provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", - approvalPolicy, - sandboxMode, + runtimeMode, + interactionMode, createdAt: messageCreatedAt, }); turnStartSucceeded = true; @@ -2214,101 +2671,501 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThreadId, setStoreThreadError], ); - const onModelSelect = useCallback( - (model: string) => { - const normalizedModel = normalizeModelSlug(model); - const resolvedModel = normalizedModel ?? baseThreadModel; - setComposerDraftModel(threadId, resolvedModel === baseThreadModel ? null : resolvedModel); + const onRespondToUserInput = useCallback( + async (requestId: ApprovalRequestId, answers: Record) => { const api = readNativeApi(); - if (api && isServerThread && activeThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", + if (!api || !activeThreadId) return; + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + await api.orchestration + .dispatchCommand({ + type: "thread.user-input.respond", commandId: newCommandId(), - threadId: activeThread.id, - model: resolvedModel, + threadId: activeThreadId, + requestId, + answers, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + setStoreThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to submit user input.", + ); }); - } - scheduleComposerFocus(); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [ - activeThread, - baseThreadModel, - isServerThread, - scheduleComposerFocus, - setComposerDraftModel, - threadId, - ], + [activeThreadId, setStoreThreadError], ); - const onEffortSelect = useCallback( - (effort: ReasoningEffort) => { - setComposerDraftEffort(threadId, effort); - scheduleComposerFocus(); + + const setActivePendingUserInputQuestionIndex = useCallback( + (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: nextQuestionIndex, + })); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], + [activePendingUserInput], ); - const onEnvModeChange = useCallback( - (mode: DraftThreadEnvMode) => { - if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + + const onSelectActivePendingUserInputOption = useCallback( + (questionId: string, optionLabel: string) => { + if (!activePendingUserInput) { + return; } - scheduleComposerFocus(); + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: { + selectedOptionLabel: optionLabel, + customAnswer: "", + }, + }, + })); + promptRef.current = ""; + setComposerCursor(0); + setComposerTrigger(null); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [activePendingUserInput], ); - const applyPromptReplacement = useCallback( - ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { expectedText?: string }, - ): boolean => { - const currentText = promptRef.current; - const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); - if ( - options?.expectedText !== undefined && - currentText.slice(safeStart, safeEnd) !== options.expectedText - ) { - return false; + const onChangeActivePendingUserInputCustomAnswer = useCallback( + (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + if (!activePendingUserInput) { + return; } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - promptRef.current = next.text; - setPrompt(next.text); - setComposerCursor(next.cursor); - setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(next.cursor); - }); - return true; + promptRef.current = value; + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[questionId], + value, + ), + }, + })); + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention + ? null + : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + ); }, - [setPrompt], + [activePendingUserInput], ); - const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { - const editorSnapshot = composerEditorRef.current?.readSnapshot(); - if (editorSnapshot) { - return editorSnapshot; + const onAdvanceActivePendingUserInput = useCallback(() => { + if (!activePendingUserInput || !activePendingProgress) { + return; } - return { value: promptRef.current, cursor: composerCursor }; - }, [composerCursor]); + if (activePendingProgress.isLastQuestion) { + if (activePendingResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } + return; + } + setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + }, [ + activePendingProgress, + activePendingResolvedAnswers, + activePendingUserInput, + onRespondToUserInput, + setActivePendingUserInputQuestionIndex, + ]); - const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number }; - trigger: ComposerTrigger | null; - } => { - const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); - return { - snapshot, - trigger: detectComposerTrigger(snapshot.value, expandedCursor), - }; - }, [readComposerSnapshot]); + const onPreviousActivePendingUserInputQuestion = useCallback(() => { + if (!activePendingProgress) { + return; + } + setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + + const onSubmitPlanFollowUp = useCallback( + async ({ + text, + interactionMode: nextInteractionMode, + }: { + text: string; + interactionMode: "default" | "plan"; + }) => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } - const onSelectComposerItem = useCallback( - (item: ComposerCommandItem) => { - if (composerSelectLockRef.current) return; - composerSelectLockRef.current = true; + const trimmed = text.trim(); + if (!trimmed) { + return; + } + + const threadIdForSend = activeThread.id; + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + + sendInFlightRef.current = true; + setSendPhase("sending-turn"); + setThreadError(threadIdForSend, null); + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: trimmed, + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + shouldAutoScrollRef.current = true; + forceStickToBottom(); + + try { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { model: selectedModel } : {}), + runtimeMode, + interactionMode: nextInteractionMode, + }); + + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: trimmed, + attachments: [], + }, + provider: selectedProvider, + model: selectedModel || undefined, + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + runtimeMode, + interactionMode: nextInteractionMode, + createdAt: messageCreatedAt, + }); + sendInFlightRef.current = false; + setSendPhase("idle"); + } catch (err) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send plan follow-up.", + ); + sendInFlightRef.current = false; + setSendPhase("idle"); + } + }, + [ + activeThread, + forceStickToBottom, + isConnecting, + isSendBusy, + isServerThread, + persistThreadSettingsForNextTurn, + runtimeMode, + selectedModel, + selectedModelOptionsForDispatch, + selectedProvider, + setThreadError, + settings.enableAssistantStreaming, + ], + ); + + const onImplementPlanInNewThread = useCallback(async () => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !activeProject || + !activeProposedPlan || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + + const createdAt = new Date().toISOString(); + const nextThreadId = newThreadId(); + const planMarkdown = activeProposedPlan.planMarkdown; + const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadModel: ModelSlug = + selectedModel || + (activeThread.model as ModelSlug) || + (activeProject.model as ModelSlug) || + DEFAULT_MODEL; + + sendInFlightRef.current = true; + setSendPhase("sending-turn"); + const finish = () => { + sendInFlightRef.current = false; + setSendPhase("idle"); + }; + + await api.orchestration + .dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: nextThreadId, + projectId: activeProject.id, + title: nextThreadTitle, + model: nextThreadModel, + runtimeMode, + interactionMode: "default", + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt, + }) + .then(() => + api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: implementationPrompt, + attachments: [], + }, + provider: selectedProvider, + model: selectedModel || undefined, + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + runtimeMode, + interactionMode: "default", + createdAt, + }), + ) + .then(() => api.orchestration.getSnapshot()) + .then((snapshot) => { + syncServerReadModel(snapshot); + return navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + }) + .catch(async (err) => { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: nextThreadId, + }) + .catch(() => undefined); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + toastManager.add({ + type: "error", + title: "Could not start implementation thread", + description: + err instanceof Error ? err.message : "An error occurred while creating the new thread.", + }); + }) + .then(finish, finish); + }, [ + activeProject, + activeProposedPlan, + activeThread, + isConnecting, + isSendBusy, + isServerThread, + navigate, + runtimeMode, + selectedModel, + selectedModelOptionsForDispatch, + selectedProvider, + settings.enableAssistantStreaming, + syncServerReadModel, + ]); + + const onProviderModelSelect = useCallback( + (provider: ProviderKind, model: ModelSlug) => { + if (!activeThread) return; + if (cursorModelSelectionLockedReason !== null && provider === "cursor") { + scheduleComposerFocus(); + return; + } + if (lockedProvider !== null && provider !== lockedProvider) { + scheduleComposerFocus(); + return; + } + const resolvedModel = + provider === "cursor" + ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) + : resolveModelSlugForProvider(provider, model); + setComposerDraftProvider(activeThread.id, provider); + setComposerDraftModel(activeThread.id, resolvedModel); + scheduleComposerFocus(); + }, + [ + activeThread, + cursorModelSelectionLockedReason, + lockedProvider, + scheduleComposerFocus, + setComposerDraftModel, + setComposerDraftProvider, + ], + ); + 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); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftEffort, threadId], + ); + const onCodexFastModeChange = useCallback( + (enabled: boolean) => { + setComposerDraftCodexFastMode(threadId, enabled); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + ); + const onEnvModeChange = useCallback( + (mode: DraftThreadEnvMode) => { + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { envMode: mode }); + } + scheduleComposerFocus(); + }, + [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + ); + + const applyPromptReplacement = useCallback( + ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { expectedText?: string }, + ): boolean => { + const currentText = promptRef.current; + const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + if ( + options?.expectedText !== undefined && + currentText.slice(safeStart, safeEnd) !== options.expectedText + ) { + return false; + } + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + promptRef.current = next.text; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [activePendingQuestion.id]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], + next.text, + ), + }, + })); + } else { + setPrompt(next.text); + } + setComposerCursor(next.cursor); + setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(next.cursor); + }); + return true; + }, + [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + ); + + const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { + const editorSnapshot = composerEditorRef.current?.readSnapshot(); + if (editorSnapshot) { + return editorSnapshot; + } + return { value: promptRef.current, cursor: composerCursor }; + }, [composerCursor]); + + const resolveActiveComposerTrigger = useCallback((): { + snapshot: { value: string; cursor: number }; + trigger: ComposerTrigger | null; + } => { + const snapshot = readComposerSnapshot(); + const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); + return { + snapshot, + trigger: detectComposerTrigger(snapshot.value, expandedCursor), + }; + }, [readComposerSnapshot]); + + const onSelectComposerItem = useCallback( + (item: ComposerCommandItem) => { + if (composerSelectLockRef.current) return; + composerSelectLockRef.current = true; window.requestAnimationFrame(() => { composerSelectLockRef.current = false; }); @@ -2328,7 +3185,17 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } if (item.type === "slash-command") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { + if (item.command === "model") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { + expectedText: expectedToken, + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: expectedToken, }); if (applied) { @@ -2336,7 +3203,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - onModelSelect(item.model); + onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: expectedToken, }); @@ -2344,7 +3211,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); } }, - [applyPromptReplacement, onModelSelect, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + onProviderModelSelect, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); @@ -2375,6 +3247,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const onPromptChange = useCallback( (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + if (activePendingProgress?.activeQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + cursorAdjacentToMention, + ); + return; + } promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); @@ -2387,7 +3268,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [setPrompt], + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, + setPrompt, + ], ); const onComposerCommandKey = ( @@ -2520,12 +3406,14 @@ export default function ChatView({ threadId }: ChatViewProps) { respondingRequestIds={respondingRequestIds} onRespondToApproval={onRespondToApproval} /> + {/* Messages */}

0} + hasMessages={timelineEntries.length > 0} isWorking={isWorking} activeTurnInProgress={!latestTurnSettled} activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} @@ -2556,6 +3444,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + workspaceRoot={activeProject?.cwd ?? undefined} />
@@ -2576,8 +3465,32 @@ export default function ChatView({ threadId }: ChatViewProps) { onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} > + {pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + {/* Textarea area */} -
+
{composerMenuOpen && (
)} - {composerImages.length > 0 && ( + {pendingUserInputs.length === 0 && composerImages.length > 0 && (
{composerImages.map((image) => (
@@ -2675,18 +3592,120 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Bottom toolbar */}
- {/* Model picker */} - + {/* Provider/model picker */} + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + + {selectedProvider === "cursor" ? ( + <> + {hasSelectedCursorTraits && ( + + )} + + {selectedCursorModel && + selectedCursorModelCapabilities && + hasSelectedCursorTraits && ( + <> + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + + )} + + ) : selectedProvider === "codex" && selectedEffort != null ? ( + <> + + + + ) : null} {/* Divider */} - {/* Reasoning effort */} - + {/* Interaction mode toggle */} + {/* Divider */} @@ -2697,7 +3716,6 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - disabled={isSwitchingRuntimeMode} onClick={() => void handleRuntimeModeChange( runtimeMode === "full-access" ? "approval-required" : "full-access", @@ -2721,10 +3739,41 @@ export default function ChatView({ threadId }: ChatViewProps) { {isPreparingWorktree ? ( Preparing worktree... ) : null} - {phase === "running" ? ( + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} + +
+ ) : phase === "running" ? ( - ) : ( - ) : ( - - )} - - )} +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) + ) : ( + + ) + ) : null}
@@ -3070,7 +4168,9 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ {approval.requestKind === "command" ? "Command approval requested" - : "File-change approval requested"} + : approval.requestKind === "file-read" + ? "File-read approval requested" + : "File-change approval requested"} ; +} + +const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { + if (!activePlan) return null; + + return ( +
+
+
+ Plan + + Updated {formatTimestamp(activePlan.createdAt)} + +
+ {activePlan.explanation ? ( +

{activePlan.explanation}

+ ) : null} +
+ {activePlan.steps.map((step) => ( +
+ + {step.status === "inProgress" + ? "In progress" + : step.status === "completed" + ? "Done" + : "Pending"} + +
{step.step}
+
+ ))} +
+
+
+ ); +}); + +interface PendingUserInputPanelProps { + pendingUserInputs: PendingUserInput[]; + respondingRequestIds: ApprovalRequestId[]; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; +} + +const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ + pendingUserInputs, + respondingRequestIds, + answers, + questionIndex, + onSelectOption, +}: PendingUserInputPanelProps) { + if (pendingUserInputs.length === 0) return null; + const activePrompt = pendingUserInputs[0]; + if (!activePrompt) return null; + + return ( + + ); +}); + +const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ + prompt, + isResponding, + answers, + questionIndex, + onSelectOption, +}: { + prompt: PendingUserInput; + isResponding: boolean; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; +}) { + const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); + const activeQuestion = progress.activeQuestion; + + if (!activeQuestion) { + return null; + } + + return ( +
+
+ + {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} + +
{activeQuestion.question}
+
+
+ {activeQuestion.options.map((option) => { + const isSelected = progress.selectedOptionLabel === option.label; + return ( + + ); + })} +
+
+ ); +}); + +const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ + planTitle, +}: { + planTitle: string | null; +}) { + return ( +
+
+ Plan ready + {planTitle ? ( + {planTitle} + ) : null} +
+ {/*
+ Review the plan +
*/} +
+ ); +}); + const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -3186,7 +4435,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { }) { const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); - const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); + const directoryPathsKey = useMemo( + () => collectDirectoryPaths(treeNodes).join("\u0000"), + [treeNodes], + ); const allDirectoryExpansionState = useMemo( () => buildDirectoryExpansionState( @@ -3195,8 +4447,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { ), [allDirectoriesExpanded, directoryPathsKey], ); - const [expandedDirectories, setExpandedDirectories] = useState>( - () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), + const [expandedDirectories, setExpandedDirectories] = useState>(() => + buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), ); useEffect(() => { setExpandedDirectories(allDirectoryExpansionState); @@ -3278,9 +4530,176 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { ); }; + return
{treeNodes.map((node) => renderTreeNode(node, 0))}
; +}); + +const ProposedPlanCard = memo(function ProposedPlanCard({ + planMarkdown, + cwd, + workspaceRoot, +}: { + planMarkdown: string; + cwd: string | undefined; + workspaceRoot: string | undefined; +}) { + const [expanded, setExpanded] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [savePath, setSavePath] = useState(""); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const savePathInputId = useId(); + const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; + const lineCount = planMarkdown.split("\n").length; + const canCollapse = planMarkdown.length > 900 || lineCount > 20; + const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); + const saveContents = normalizePlanMarkdownForExport(planMarkdown); + + const handleDownload = () => { + downloadTextFile(downloadFilename, saveContents); + }; + + const openSaveDialog = () => { + if (!workspaceRoot) { + toastManager.add({ + type: "error", + title: "Workspace path is unavailable", + description: "This thread does not have a workspace path to save into.", + }); + return; + } + setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); + setIsSaveDialogOpen(true); + }; + + const handleSaveToWorkspace = () => { + const api = readNativeApi(); + const relativePath = savePath.trim(); + if (!api || !workspaceRoot) { + return; + } + if (!relativePath) { + toastManager.add({ + type: "warning", + title: "Enter a workspace path", + }); + return; + } + + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }) + .then((result) => { + setIsSaveDialogOpen(false); + toastManager.add({ + type: "success", + title: "Plan saved to workspace", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred while saving.", + }); + }) + .then( + () => { + setIsSavingToWorkspace(false); + }, + () => { + setIsSavingToWorkspace(false); + }, + ); + }; + return ( -
- {treeNodes.map((node) => renderTreeNode(node, 0))} +
+
+
+ Plan +

{title}

+
+ + } + > + + + Download as markdown + + Save to workspace + + + +
+
+
+ + {canCollapse && !expanded ? ( +
+ ) : null} +
+ {canCollapse ? ( +
+ +
+ ) : null} +
+ + { + if (!isSavingToWorkspace) { + setIsSaveDialogOpen(open); + } + }} + > + + + Save plan to workspace + + Enter a path relative to {workspaceRoot ?? "the workspace"}. + + + + + + + + + + +
); }); @@ -3305,10 +4724,12 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + workspaceRoot: string | undefined; } type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; type TimelineWorkEntry = Extract["entry"]; type TimelineRow = | { @@ -3324,8 +4745,19 @@ type TimelineRow = message: TimelineMessage; showCompletionDivider: boolean; } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: TimelineProposedPlan; + } | { 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); +} + const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, @@ -3346,6 +4778,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + workspaceRoot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -3403,6 +4836,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ continue; } + if (timelineEntry.kind === "proposed-plan") { + nextRows.push({ + kind: "proposed-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + proposedPlan: timelineEntry.proposedPlan, + }); + continue; + } + nextRows.push({ kind: "message", id: timelineEntry.id, @@ -3477,6 +4920,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ const row = rows[index]; 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; return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, @@ -3764,6 +5208,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ ); })()} + {row.kind === "proposed-plan" && ( +
+ +
+ )} + {row.kind === "working" && (
@@ -3823,52 +5277,403 @@ const MessagesTimeline = memo(function MessagesTimeline({ ); }); -const ModelPicker = memo(function ModelPicker(props: { - model: string; - options: ReadonlyArray<{ slug: string; name: string }>; - onModelChange: (model: string) => void; +function isAvailableProviderOption( + option: (typeof PROVIDER_OPTIONS)[number], +): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available && option.value !== "claudeCode"; +} + +const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); +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[]; +}): 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), + ), + ], + }; +} + +const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeCode: ClaudeAI, + cursor: CursorIcon, +}; + +function resolveModelForProviderPicker( + provider: ProviderKind, + value: string, + options: ReadonlyArray<{ slug: string; name: string }>, +): ModelSlug | null { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const direct = options.find((option) => option.slug === trimmedValue); + if (direct) { + return direct.slug; + } + + const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmedValue, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + if (resolved) { + return resolved.slug; + } + + if (provider === "cursor") { + return parseCursorModelSelection(normalized).family; + } + + return null; +} + +const ProviderModelPicker = memo(function ProviderModelPicker(props: { + provider: ProviderKind; + model: ModelSlug; + lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record>; + disabled?: boolean; + onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; + const selectedModelLabel = + selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + return ( - + + } + > + + + + + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const isDisabledByProviderLock = + props.lockedProvider !== null && props.lockedProvider !== option.value; + return ( + + + + + + { + if (props.disabled) return; + if (isDisabledByProviderLock) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker( + option.value, + value, + props.modelOptionsByProvider[option.value], + ); + if (!resolvedModel) return; + props.onProviderModelChange(option.value, resolvedModel); + setIsMenuOpen(false); + }} + > + {props.modelOptionsByProvider[option.value].map((modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ))} + + + + + ); + })} + {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 ( + + + ); + })} + + ); }); -const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { - effort: ReasoningEffort; - onEffortChange: (effort: ReasoningEffort) => void; +const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { + effort: CodexReasoningEffort; + fastModeEnabled: boolean; + options: ReadonlyArray; + onEffortChange: (effort: CodexReasoningEffort) => void; + onFastModeChange: (enabled: boolean) => void; }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + const triggerLabel = [ + reasoningLabelByOption[props.effort], + ...(props.fastModeEnabled ? ["Fast"] : []), + ] + .filter(Boolean) + .join(" · "); + return ( - + + } + > + {triggerLabel} + + + +
Reasoning
+ { + if (!value) return; + const nextEffort = props.options.find((option) => option === value); + if (!nextEffort) return; + props.onEffortChange(nextEffort); + }} + > + {props.options.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+
+ + ); +}); + +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 + +
+ )} +
+
); }); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index c74d785cda..d94d74c154 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -142,3 +142,200 @@ export const Zed: Icon = (props) => { ); }; + +export const OpenAI: Icon = (props) => ( + + + +); + +export const ClaudeAI: Icon = (props) => ( + + + +); + +export const Gemini: Icon = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const OpenCodeIcon: Icon = (props) => ( + + + + + + + + + + + +); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 018e89d14b..0207f289b5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_RUNTIME_MODE, DEFAULT_MODEL, type DesktopUpdateState, ProjectId, @@ -435,6 +436,7 @@ export default function Sidebar() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, }); await navigate({ diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b9990d7fdf..7e6805c96d 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -4,6 +4,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, + parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -43,6 +44,18 @@ describe("detectComposerTrigger", () => { rangeEnd: text.length, }); }); + + it("detects non-model slash commands while typing", () => { + const text = "/pl"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "pl", + rangeStart: 0, + rangeEnd: text.length, + }); + }); }); describe("replaceTextRange", () => { @@ -111,3 +124,17 @@ describe("isCollapsedCursorAdjacentToMention", () => { expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); }); }); + +describe("parseStandaloneComposerSlashCommand", () => { + it("parses standalone /plan command", () => { + expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan"); + }); + + it("parses standalone /default command", () => { + expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); + }); + + it("ignores slash commands with extra message text", () => { + expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 843a5255c3..f2e367bcf0 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,6 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; +export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -9,6 +10,8 @@ export interface ComposerTrigger { rangeEnd: number; } +const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; + function clampCursor(text: string, cursor: number): number { if (!Number.isFinite(cursor)) return text.length; return Math.max(0, Math.min(text.length, Math.floor(cursor))); @@ -121,7 +124,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if ("model".startsWith(commandQuery.toLowerCase())) { + if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", query: commandQuery, @@ -157,6 +160,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +export function parseStandaloneComposerSlashCommand(text: string): Exclude< + ComposerSlashCommand, + "model" +> | null { + const match = /^\/(plan|default)\s*$/i.exec(text.trim()); + if (!match) { + return null; + } + return match[1]?.toLowerCase() === "plan" ? "plan" : "default"; +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 12c46a21b1..ce0113058a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -184,6 +184,8 @@ describe("composerDraftStore project draft thread mapping", () => { branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ @@ -191,6 +193,8 @@ describe("composerDraftStore project draft thread mapping", () => { branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); }); @@ -329,6 +333,33 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); +describe("composerDraftStore codex fast mode", () => { + const threadId = ThreadId.makeUnsafe("thread-service-tier"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("stores codex fast mode in the draft", () => { + const store = useComposerDraftStore.getState(); + store.setCodexFastMode(threadId, true); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true); + }); + + it("clears codex fast mode when reset to the default", () => { + const store = useComposerDraftStore.getState(); + store.setCodexFastMode(threadId, true); + store.setCodexFastMode(threadId, false); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); + describe("composerDraftStore setModel", () => { const threadId = ThreadId.makeUnsafe("thread-model"); @@ -348,3 +379,75 @@ describe("composerDraftStore setModel", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex"); }); }); + +describe("composerDraftStore setProvider", () => { + const threadId = ThreadId.makeUnsafe("thread-provider"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("persists provider-only selection even when prompt/model are empty", () => { + const store = useComposerDraftStore.getState(); + + store.setProvider(threadId, "cursor"); + + 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, "cursor"); + store.setProvider(threadId, null); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); + +describe("composerDraftStore runtime and interaction settings", () => { + const threadId = ThreadId.makeUnsafe("thread-settings"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("stores runtime mode overrides in the composer draft", () => { + const store = useComposerDraftStore.getState(); + + store.setRuntimeMode(threadId, "approval-required"); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe( + "approval-required", + ); + }); + + it("stores interaction mode overrides in the composer draft", () => { + const store = useComposerDraftStore.getState(); + + store.setInteractionMode(threadId, "plan"); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe( + "plan", + ); + }); + + it("removes empty settings-only drafts when overrides are cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setRuntimeMode(threadId, "approval-required"); + store.setInteractionMode(threadId, "plan"); + store.setRuntimeMode(threadId, null); + store.setInteractionMode(threadId, null); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 386e4ffaad..cec12faa6d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,12 +1,19 @@ import { - DEFAULT_REASONING, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, - REASONING_OPTIONS, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, - normalizeModelSlug, - type ReasoningEffort, + type CodexReasoningEffort, + type ProviderKind, + type ProviderInteractionMode, + type RuntimeMode, } from "@t3tools/contracts"; -import type { ChatImageAttachment } from "./types"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatImageAttachment, +} from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -29,13 +36,20 @@ export interface ComposerImageAttachment extends Omit void; setDraftThreadContext: ( @@ -92,14 +114,23 @@ interface ComposerDraftStoreState { projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; + setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; setModel: (threadId: ThreadId, model: string | null | undefined) => void; - setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void; + setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; + setInteractionMode: ( + threadId: ThreadId, + interactionMode: ProviderInteractionMode | null | undefined, + ) => void; + setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void; + setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; @@ -129,11 +160,17 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, + provider: null, model: null, + runtimeMode: null, + interactionMode: null, effort: null, + codexFastMode: false, }) as ComposerThreadDraftState; -const REASONING_EFFORT_VALUES = new Set(REASONING_OPTIONS); +const REASONING_EFFORT_VALUES = new Set( + REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, +); function createEmptyThreadDraft(): ComposerThreadDraftState { return { @@ -141,8 +178,12 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { images: [], nonPersistedImageIds: [], persistedAttachments: [], + provider: null, model: null, + runtimeMode: null, + interactionMode: null, effort: null, + codexFastMode: false, }; } @@ -157,11 +198,19 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && + draft.provider === null && draft.model === null && - draft.effort === null + draft.runtimeMode === null && + draft.interactionMode === null && + draft.effort === null && + draft.codexFastMode === false ); } +function normalizeProviderKind(value: unknown): ProviderKind | null { + return value === "codex" || value === "claudeCode" || value === "cursor" ? value : null; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -247,6 +296,16 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), + runtimeMode: + candidateDraftThread.runtimeMode === "approval-required" || + candidateDraftThread.runtimeMode === "full-access" + ? candidateDraftThread.runtimeMode + : DEFAULT_RUNTIME_MODE, + interactionMode: + candidateDraftThread.interactionMode === "plan" || + candidateDraftThread.interactionMode === "default" + ? candidateDraftThread.interactionMode + : DEFAULT_INTERACTION_MODE, branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), @@ -273,6 +332,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, branch: null, worktreePath: null, envMode: "local", @@ -305,22 +366,50 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer return normalized ? [normalized] : []; }) : []; + const provider = normalizeProviderKind(draftCandidate.provider); const model = - typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null; + typeof draftCandidate.model === "string" + ? normalizeModelSlug(draftCandidate.model, provider ?? "codex") + : null; + const runtimeMode = + draftCandidate.runtimeMode === "approval-required" || + draftCandidate.runtimeMode === "full-access" + ? draftCandidate.runtimeMode + : null; + const interactionMode = + draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" + ? draftCandidate.interactionMode + : null; const effortCandidate = typeof draftCandidate.effort === "string" ? draftCandidate.effort : null; const effort = - effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as ReasoningEffort) - ? (effortCandidate as ReasoningEffort) + effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort) + ? (effortCandidate as CodexReasoningEffort) : null; - if (prompt.length === 0 && attachments.length === 0 && !model && !effort) { + const codexFastMode = + draftCandidate.codexFastMode === true || + (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + if ( + prompt.length === 0 && + attachments.length === 0 && + !provider && + !model && + !runtimeMode && + !interactionMode && + !effort && + !codexFastMode + ) { continue; } nextDraftsByThreadId[threadId as ThreadId] = { prompt, attachments, + ...(provider ? { provider } : {}), ...(model ? { model } : {}), + ...(runtimeMode ? { runtimeMode } : {}), + ...(interactionMode ? { interactionMode } : {}), ...(effort ? { effort } : {}), + ...(codexFastMode ? { codexFastMode } : {}), }; } return { @@ -422,8 +511,12 @@ function toHydratedThreadDraft( images: hydrateImagesFromPersisted(persistedDraft.attachments), nonPersistedImageIds: [], persistedAttachments: persistedDraft.attachments, + provider: persistedDraft.provider ?? null, model: persistedDraft.model ?? null, + runtimeMode: persistedDraft.runtimeMode ?? null, + interactionMode: persistedDraft.interactionMode ?? null, effort: persistedDraft.effort ?? null, + codexFastMode: persistedDraft.codexFastMode === true, }; } @@ -470,6 +563,11 @@ export const useComposerDraftStore = create()( const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), + runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + options?.interactionMode ?? + existingThread?.interactionMode ?? + DEFAULT_INTERACTION_MODE, branch: options?.branch === undefined ? (existingThread?.branch ?? null) @@ -484,6 +582,8 @@ export const useComposerDraftStore = create()( existingThread && existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && + existingThread.runtimeMode === nextDraftThread.runtimeMode && + existingThread.interactionMode === nextDraftThread.interactionMode && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; @@ -538,6 +638,8 @@ export const useComposerDraftStore = create()( options.createdAt === undefined ? existing.createdAt : options.createdAt || existing.createdAt, + runtimeMode: options.runtimeMode ?? existing.runtimeMode, + interactionMode: options.interactionMode ?? existing.interactionMode, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: @@ -547,6 +649,8 @@ export const useComposerDraftStore = create()( const isUnchanged = nextDraftThread.projectId === existing.projectId && nextDraftThread.createdAt === existing.createdAt && + nextDraftThread.runtimeMode === existing.runtimeMode && + nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode; @@ -674,6 +778,33 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setProvider: (threadId, provider) => { + if (threadId.length === 0) { + return; + } + const normalizedProvider = normalizeProviderKind(provider); + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing && normalizedProvider === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.provider === normalizedProvider) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + provider: normalizedProvider, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, setModel: (threadId, model) => { if (threadId.length === 0) { return; @@ -701,12 +832,70 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setRuntimeMode: (threadId, runtimeMode) => { + if (threadId.length === 0) { + return; + } + const nextRuntimeMode = + runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing && nextRuntimeMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.runtimeMode === nextRuntimeMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + runtimeMode: nextRuntimeMode, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + setInteractionMode: (threadId, interactionMode) => { + if (threadId.length === 0) { + return; + } + const nextInteractionMode = + interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing && nextInteractionMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.interactionMode === nextInteractionMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + interactionMode: nextInteractionMode, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, setEffort: (threadId, effort) => { if (threadId.length === 0) { return; } const nextEffort = - effort && REASONING_EFFORT_VALUES.has(effort) && effort !== DEFAULT_REASONING + effort && + REASONING_EFFORT_VALUES.has(effort) && + effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex ? effort : null; set((state) => { @@ -731,6 +920,33 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setCodexFastMode: (threadId, enabled) => { + if (threadId.length === 0) { + return; + } + const nextCodexFastMode = enabled === true; + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing && nextCodexFastMode === false) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.codexFastMode === nextCodexFastMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + codexFastMode: nextCodexFastMode, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, addImage: (threadId, image) => { if (threadId.length === 0) { return; @@ -963,8 +1179,12 @@ export const useComposerDraftStore = create()( if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && + draft.provider === null && draft.model === null && - draft.effort === null + draft.runtimeMode === null && + draft.interactionMode === null && + draft.effort === null && + draft.codexFastMode === false ) { continue; } @@ -975,9 +1195,21 @@ export const useComposerDraftStore = create()( if (draft.model) { persistedDraft.model = draft.model; } + if (draft.provider) { + persistedDraft.provider = draft.provider; + } + if (draft.runtimeMode) { + persistedDraft.runtimeMode = draft.runtimeMode; + } + if (draft.interactionMode) { + persistedDraft.interactionMode = draft.interactionMode; + } if (draft.effort) { persistedDraft.effort = draft.effort; } + if (draft.codexFastMode) { + persistedDraft.codexFastMode = true; + } persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; } return { diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts new file mode 100644 index 0000000000..153b315356 --- /dev/null +++ b/apps/web/src/pendingUserInput.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPendingUserInputAnswers, + countAnsweredPendingUserInputQuestions, + derivePendingUserInputProgress, + findFirstUnansweredPendingUserInputQuestionIndex, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, +} from "./pendingUserInput"; + +describe("resolvePendingUserInputAnswer", () => { + it("prefers a custom answer over a selected option", () => { + expect( + resolvePendingUserInputAnswer({ + selectedOptionLabel: "Keep current envelope", + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("falls back to the selected option", () => { + expect( + resolvePendingUserInputAnswer({ + selectedOptionLabel: "Scaffold only", + }), + ).toBe("Scaffold only"); + }); + + it("clears the preset selection when a custom answer is entered", () => { + expect( + setPendingUserInputCustomAnswer( + { + selectedOptionLabel: "Preserve existing tags", + }, + "doesn't matter", + ), + ).toEqual({ + selectedOptionLabel: undefined, + customAnswer: "doesn't matter", + }); + }); +}); + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers( + [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [ + { + label: "Keep current envelope", + description: "Preserve current wire format", + }, + ], + }, + ], + { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + compat: { + customAnswer: "Keep the current envelope for one release window", + }, + }, + ), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); + + it("returns null when any question is unanswered", () => { + expect( + buildPendingUserInputAnswers( + [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + ], + {}, + ), + ).toBeNull(); + }); +}); + +describe("pending user input question progress", () => { + const questions = [ + { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { + label: "Orchestration-first", + description: "Focus on orchestration first", + }, + ], + }, + { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [ + { + label: "Keep current envelope", + description: "Preserve current wire format", + }, + ], + }, + ] as const; + + it("counts only answered questions", () => { + expect( + countAnsweredPendingUserInputQuestions(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }), + ).toBe(1); + }); + + it("finds the first unanswered question", () => { + expect( + findFirstUnansweredPendingUserInputQuestionIndex(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }), + ).toBe(1); + }); + + it("returns the last question index when all answers are complete", () => { + expect( + findFirstUnansweredPendingUserInputQuestionIndex(questions, { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + compat: { + customAnswer: "Keep it for one release window", + }, + }), + ).toBe(1); + }); + + it("derives the active question and advancement state", () => { + expect( + derivePendingUserInputProgress( + questions, + { + scope: { + selectedOptionLabel: "Orchestration-first", + }, + }, + 0, + ), + ).toMatchObject({ + questionIndex: 0, + activeQuestion: questions[0], + selectedOptionLabel: "Orchestration-first", + customAnswer: "", + resolvedAnswer: "Orchestration-first", + answeredQuestionCount: 1, + isLastQuestion: false, + isComplete: false, + canAdvance: true, + }); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts new file mode 100644 index 0000000000..dd592bd62b --- /dev/null +++ b/apps/web/src/pendingUserInput.ts @@ -0,0 +1,122 @@ +import type { UserInputQuestion } from "@t3tools/contracts"; + +export interface PendingUserInputDraftAnswer { + selectedOptionLabel?: string; + customAnswer?: string; +} + +export interface PendingUserInputProgress { + questionIndex: number; + activeQuestion: UserInputQuestion | null; + activeDraft: PendingUserInputDraftAnswer | undefined; + selectedOptionLabel: string | undefined; + customAnswer: string; + resolvedAnswer: string | null; + usingCustomAnswer: boolean; + answeredQuestionCount: number; + isLastQuestion: boolean; + isComplete: boolean; + canAdvance: boolean; +} + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolvePendingUserInputAnswer( + draft: PendingUserInputDraftAnswer | undefined, +): string | null { + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer) { + return customAnswer; + } + + return normalizeDraftAnswer(draft?.selectedOptionLabel); +} + +export function setPendingUserInputCustomAnswer( + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + const selectedOptionLabel = + customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + + return { + customAnswer, + ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + }; +} + +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + if (!answer) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} + +export function countAnsweredPendingUserInputQuestions( + questions: ReadonlyArray, + draftAnswers: Record, +): number { + return questions.reduce((count, question) => { + return resolvePendingUserInputAnswer(draftAnswers[question.id]) ? count + 1 : count; + }, 0); +} + +export function findFirstUnansweredPendingUserInputQuestionIndex( + questions: ReadonlyArray, + draftAnswers: Record, +): number { + const unansweredIndex = questions.findIndex( + (question) => !resolvePendingUserInputAnswer(draftAnswers[question.id]), + ); + + return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; +} + +export function derivePendingUserInputProgress( + questions: ReadonlyArray, + draftAnswers: Record, + questionIndex: number, +): PendingUserInputProgress { + const normalizedQuestionIndex = + questions.length === 0 + ? 0 + : Math.max(0, Math.min(questionIndex, questions.length - 1)); + const activeQuestion = questions[normalizedQuestionIndex] ?? null; + const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; + const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); + const customAnswer = activeDraft?.customAnswer ?? ""; + const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); + const isLastQuestion = + questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; + + return { + questionIndex: normalizedQuestionIndex, + activeQuestion, + activeDraft, + selectedOptionLabel: activeDraft?.selectedOptionLabel, + customAnswer, + resolvedAnswer, + usingCustomAnswer: customAnswer.trim().length > 0, + answeredQuestionCount, + isLastQuestion, + isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, + canAdvance: Boolean(resolvedAnswer), + }; +} diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts new file mode 100644 index 0000000000..02e4e78aed --- /dev/null +++ b/apps/web/src/proposedPlan.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPlanImplementationThreadTitle, + buildPlanImplementationPrompt, + buildProposedPlanMarkdownFilename, + proposedPlanTitle, +} from "./proposedPlan"; + +describe("proposedPlanTitle", () => { + it("reads the first markdown heading as the plan title", () => { + expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); + }); + + it("returns null when the plan has no heading", () => { + expect(proposedPlanTitle("- step 1")).toBeNull(); + }); +}); + +describe("buildPlanImplementationPrompt", () => { + it("formats the plan exactly like the Codex follow-up handoff prompt", () => { + expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( + "PLEASE IMPLEMENT THIS PLAN:\n## Ship it\n\n- step 1", + ); + }); +}); + +describe("buildPlanImplementationThreadTitle", () => { + it("uses the plan heading when building the implementation thread title", () => { + expect(buildPlanImplementationThreadTitle("# Integrate RPC\n\nBody")).toBe( + "Implement Integrate RPC", + ); + }); + + it("falls back when the plan has no markdown heading", () => { + expect(buildPlanImplementationThreadTitle("- step 1")).toBe("Implement plan"); + }); +}); + +describe("buildProposedPlanMarkdownFilename", () => { + it("derives a stable markdown filename from the plan heading", () => { + expect(buildProposedPlanMarkdownFilename("# Integrate Effect RPC Into Server App")).toBe( + "integrate-effect-rpc-into-server-app.md", + ); + }); + + it("falls back to a generic filename when the plan has no heading", () => { + expect(buildProposedPlanMarkdownFilename("- step 1")).toBe("plan.md"); + }); +}); diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts new file mode 100644 index 0000000000..fdfe859726 --- /dev/null +++ b/apps/web/src/proposedPlan.ts @@ -0,0 +1,30 @@ +export function proposedPlanTitle(planMarkdown: string): string | null { + const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +} + +function sanitizePlanFileSegment(input: string): string { + const sanitized = input + .toLowerCase() + .replace(/[`'".,!?()[\]{}]+/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "plan"; +} + +export function buildPlanImplementationPrompt(planMarkdown: string): string { + return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; +} + +export function buildPlanImplementationThreadTitle(planMarkdown: string): string { + const title = proposedPlanTitle(planMarkdown); + if (!title) { + return "Implement plan"; + } + return `Implement ${title}`; +} + +export function buildProposedPlanMarkdownFilename(planMarkdown: string): string { + const title = proposedPlanTitle(planMarkdown); + return `${sanitizePlanFileSegment(title ?? "plan")}.md`; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e05df25805..4221bb9148 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,14 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; +import { type ProviderKind } from "@t3tools/contracts"; +import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; -import { useStore } from "../store"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -33,33 +33,90 @@ const THEME_OPTIONS = [ }, ] as const; -const RUNTIME_MODE_OPTIONS = [ +const MODEL_PROVIDER_SETTINGS: Array<{ + provider: ProviderKind; + title: string; + description: string; + placeholder: string; + example: string; +}> = [ { - value: "full-access", - label: "Full access", - description: "Allow commands to run without confirmation prompts.", + provider: "codex", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", }, { - value: "approval-required", - label: "Supervised", - description: "Require approval prompts before command execution.", + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", }, ] as const; +function getCustomModelsForProvider( + settings: ReturnType["settings"], + provider: ProviderKind, +) { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "cursor": + return settings.customCursorModels; + case "codex": + default: + return settings.customCodexModels; + } +} + +function getDefaultCustomModelsForProvider( + defaults: ReturnType["defaults"], + provider: ProviderKind, +) { + switch (provider) { + case "claudeCode": + return defaults.customClaudeModels; + case "cursor": + return defaults.customCursorModels; + case "codex": + default: + return defaults.customCodexModels; + } +} + +function patchCustomModels(provider: ProviderKind, models: string[]) { + switch (provider) { + case "claudeCode": + return { customClaudeModels: models }; + case "cursor": + return { customCursorModels: models }; + case "codex": + default: + return { customCodexModels: models }; + } +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); - const runtimeMode = useStore((store) => store.runtimeMode); - const setRuntimeMode = useStore((store) => store.setRuntimeMode); const { settings, defaults, updateSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [customModelInput, setCustomModelInput] = useState(""); - const [customModelError, setCustomModelError] = useState(null); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeCode: "", + cursor: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; - const customCodexModels = settings.customCodexModels; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -79,40 +136,60 @@ function SettingsRouteView() { }); }, [keybindingsConfigPath]); - const addCustomModel = useCallback(() => { - const normalized = normalizeModelSlug(customModelInput); + const addCustomModel = useCallback((provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = getCustomModelsForProvider(settings, provider); + const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { - setCustomModelError("Enter a model slug."); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); return; } - if (MODEL_OPTIONS.some((option) => option.slug === normalized)) { - setCustomModelError("That model is already built in."); + if (getModelOptions(provider).some((option) => option.slug === normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); return; } if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); return; } - if (customCodexModels.includes(normalized)) { - setCustomModelError("That custom model is already saved."); + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); return; } - updateSettings({ - customCodexModels: [...customCodexModels, normalized], - }); - setCustomModelInput(""); - setCustomModelError(null); - }, [customCodexModels, customModelInput, updateSettings]); + updateSettings(patchCustomModels(provider, [...customModels, normalized])); + setCustomModelInputByProvider((existing) => ({ + ...existing, + [provider]: "", + })); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, [customModelInputByProvider, settings, updateSettings]); const removeCustomModel = useCallback( - (slug: string) => { - updateSettings({ - customCodexModels: customCodexModels.filter((model) => model !== slug), - }); - setCustomModelError(null); + (provider: ProviderKind, slug: string) => { + const customModels = getCustomModelsForProvider(settings, provider); + updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug))); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); }, - [customCodexModels, updateSettings], + [settings, updateSettings], ); return ( @@ -240,128 +317,131 @@ function SettingsRouteView() {

Models

- Save additional Codex model slugs so they appear in the chat model picker. + Save additional provider model slugs so they appear in the chat model picker and + `/model` command suggestions.

-
-
- - - -
- - {customModelError ? ( -

{customModelError}

- ) : null} - -
-
-

Saved custom models: {customCodexModels.length}

- {customCodexModels.length > 0 ? ( - - ) : null} -
+
+ {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { + const provider = providerSettings.provider; + const customModels = getCustomModelsForProvider(settings, provider); + const customModelInput = customModelInputByProvider[provider]; + const customModelError = customModelErrorByProvider[provider] ?? null; + return ( +
+
+

+ {providerSettings.title} +

+

+ {providerSettings.description} +

+
+ +
+
+ - {customCodexModels.length > 0 ? ( -
- {customCodexModels.map((slug) => ( -
- - {slug} -
- ))} -
- ) : ( -
- No custom models saved yet. -
- )} -
-
- - -
-
-

Runtime Mode

-

- Select the default execution policy for this client. -

-
-
- {RUNTIME_MODE_OPTIONS.map((option) => { - const selected = runtimeMode === option.value; - return ( - + {customModelError ? ( +

{customModelError}

+ ) : null} + +
+
+

Saved custom models: {customModels.length}

+ {customModels.length > 0 ? ( + + ) : null} +
+ + {customModels.length > 0 ? ( +
+ {customModels.map((slug) => ( +
+ + {slug} + + +
+ ))} +
+ ) : ( +
+ No custom models saved yet. +
+ )} +
+
+
); })}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 26ff6221a2..6a46ec1859 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1,9 +1,14 @@ -import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { + deriveActivePlanState, + PROVIDER_OPTIONS, derivePendingApprovals, + derivePendingUserInputs, + deriveTimelineEntries, deriveWorkLogEntries, + findLatestProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, } from "./session-logic"; @@ -16,6 +21,7 @@ function makeActivity(overrides: { tone?: OrchestrationThreadActivity["tone"]; payload?: Record; turnId?: string; + sequence?: number; }): OrchestrationThreadActivity { const payload = overrides.payload ?? {}; return { @@ -26,6 +32,7 @@ function makeActivity(overrides: { tone: overrides.tone ?? "tool", payload, turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, + ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), }; } @@ -71,6 +78,248 @@ describe("derivePendingApprovals", () => { }, ]); }); + + it("maps canonical requestType payloads into pending approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-request-type", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-request-type", + requestType: "command_execution_approval", + detail: "pwd", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-request-type", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "pwd", + }, + ]); + }); + + it("clears stale pending approvals when provider reports unknown pending request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-stale-1", + requestKind: "command", + }, + }), + makeActivity({ + id: "approval-failed-stale", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + tone: "error", + payload: { + requestId: "req-stale-1", + detail: "Unknown pending permission request: req-stale-1", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([]); + }); +}); + +describe("derivePendingUserInputs", () => { + it("tracks open structured prompts and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + }), + makeActivity({ + id: "user-input-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + payload: { + requestId: "req-user-input-2", + answers: { + sandbox_mode: "workspace-write", + }, + }, + }), + makeActivity({ + id: "user-input-open-2", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-2", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [ + { + label: "yes", + description: "Continue execution", + }, + ], + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([ + { + requestId: "req-user-input-1", + createdAt: "2026-02-23T00:00:01.000Z", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + ]); + }); +}); + +describe("deriveActivePlanState", () => { + it("returns the latest plan update for the active turn", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-old", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + explanation: "Initial plan", + plan: [{ step: "Inspect code", status: "pending" }], + }, + }), + makeActivity({ + id: "plan-latest", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + explanation: "Refined plan", + plan: [{ step: "Implement Codex user input", status: "inProgress" }], + }, + }), + ]; + + expect(deriveActivePlanState(activities, TurnId.makeUnsafe("turn-1"))).toEqual({ + createdAt: "2026-02-23T00:00:02.000Z", + turnId: "turn-1", + explanation: "Refined plan", + steps: [{ step: "Implement Codex user input", status: "inProgress" }], + }); + }); +}); + +describe("findLatestProposedPlan", () => { + it("prefers the latest proposed plan for the active turn", () => { + expect( + findLatestProposedPlan( + [ + { + id: "plan:thread-1:turn:turn-1", + turnId: TurnId.makeUnsafe("turn-1"), + planMarkdown: "# Older", + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:01.000Z", + }, + { + id: "plan:thread-1:turn:turn-1", + turnId: TurnId.makeUnsafe("turn-1"), + planMarkdown: "# Latest", + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }, + { + id: "plan:thread-1:turn:turn-2", + turnId: TurnId.makeUnsafe("turn-2"), + planMarkdown: "# Different turn", + createdAt: "2026-02-23T00:00:03.000Z", + updatedAt: "2026-02-23T00:00:03.000Z", + }, + ], + TurnId.makeUnsafe("turn-1"), + ), + ).toEqual({ + id: "plan:thread-1:turn:turn-1", + turnId: "turn-1", + planMarkdown: "# Latest", + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }); + }); + + it("falls back to the most recently updated proposed plan", () => { + const latestPlan = findLatestProposedPlan( + [ + { + id: "plan:thread-1:turn:turn-1", + turnId: TurnId.makeUnsafe("turn-1"), + planMarkdown: "# First", + createdAt: "2026-02-23T00:00:01.000Z", + updatedAt: "2026-02-23T00:00:01.000Z", + }, + { + id: "plan:thread-1:turn:turn-2", + turnId: TurnId.makeUnsafe("turn-2"), + planMarkdown: "# Latest", + createdAt: "2026-02-23T00:00:02.000Z", + updatedAt: "2026-02-23T00:00:03.000Z", + }, + ], + null, + ); + + expect(latestPlan?.planMarkdown).toBe("# Latest"); + }); }); describe("deriveWorkLogEntries", () => { @@ -130,6 +379,69 @@ describe("deriveWorkLogEntries", () => { const entries = deriveWorkLogEntries(activities, undefined); expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); + + it("orders work log by activity sequence when present", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "second", + createdAt: "2026-02-23T00:00:03.000Z", + sequence: 2, + summary: "Tool call complete", + kind: "tool.completed", + }), + makeActivity({ + id: "first", + createdAt: "2026-02-23T00:00:04.000Z", + sequence: 1, + summary: "Tool call complete", + kind: "tool.completed", + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]); + }); +}); + +describe("deriveTimelineEntries", () => { + it("includes proposed plans alongside messages and work entries in chronological order", () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.makeUnsafe("message-1"), + role: "assistant", + text: "hello", + createdAt: "2026-02-23T00:00:01.000Z", + streaming: false, + }, + ], + [ + { + id: "plan:thread-1:turn:turn-1", + turnId: TurnId.makeUnsafe("turn-1"), + planMarkdown: "# Ship it", + createdAt: "2026-02-23T00:00:02.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }, + ], + [ + { + id: "work-1", + createdAt: "2026-02-23T00:00:03.000Z", + label: "Ran tests", + tone: "tool", + }, + ], + ); + + expect(entries.map((entry) => entry.kind)).toEqual(["message", "proposed-plan", "work"]); + expect(entries[1]).toMatchObject({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: "# Ship it", + }, + }); + }); }); describe("hasToolActivityForTurn", () => { @@ -200,3 +512,25 @@ describe("isLatestTurnSettled", () => { ).toBe(false); }); }); + +describe("PROVIDER_OPTIONS", () => { + it("advertises Claude Code on the Claude stack while keeping Cursor as a placeholder", () => { + const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); + const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); + expect(PROVIDER_OPTIONS).toEqual([ + { value: "codex", label: "Codex", available: true }, + { value: "claudeCode", label: "Claude Code", available: true }, + { value: "cursor", label: "Cursor", available: false }, + ]); + expect(claude).toEqual({ + value: "claudeCode", + label: "Claude Code", + available: true, + }); + expect(cursor).toEqual({ + value: "cursor", + label: "Cursor", + available: false, + }); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a44c12e79c..1a4ff430f5 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -2,19 +2,24 @@ import { ApprovalRequestId, type OrchestrationLatestTurn, type OrchestrationThreadActivity, + type OrchestrationProposedPlanId, type ProviderKind, + type UserInputQuestion, type TurnId, } from "@t3tools/contracts"; -import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; +import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; + +export type ProviderPickerKind = ProviderKind | "claudeCode"; export const PROVIDER_OPTIONS: Array<{ - value: ProviderKind; + value: ProviderPickerKind; label: string; available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code (soon)", available: false }, + { value: "claudeCode", label: "Claude Code", available: true }, + { value: "cursor", label: "Cursor", available: false }, ]; export interface WorkLogEntry { @@ -27,11 +32,35 @@ export interface WorkLogEntry { export interface PendingApproval { requestId: ApprovalRequestId; - requestKind: "command" | "file-change"; + requestKind: "command" | "file-read" | "file-change"; createdAt: string; detail?: string; } +export interface PendingUserInput { + requestId: ApprovalRequestId; + createdAt: string; + questions: ReadonlyArray; +} + +export interface ActivePlanState { + createdAt: string; + turnId: TurnId | null; + explanation?: string | null; + steps: Array<{ + step: string; + status: "pending" | "inProgress" | "completed"; + }>; +} + +export interface LatestProposedPlanState { + id: OrchestrationProposedPlanId; + createdAt: string; + updatedAt: string; + turnId: TurnId | null; + planMarkdown: string; +} + export type TimelineEntry = | { id: string; @@ -39,6 +68,12 @@ export type TimelineEntry = createdAt: string; message: ChatMessage; } + | { + id: string; + kind: "proposed-plan"; + createdAt: string; + proposedPlan: ProposedPlan; + } | { id: string; kind: "work"; @@ -87,13 +122,28 @@ export function isLatestTurnSettled( return true; } +function requestKindFromRequestType( + requestType: unknown, +): PendingApproval["requestKind"] | null { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + default: + return null; + } +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { const openByRequestId = new Map(); - const ordered = [...activities].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); + const ordered = [...activities].toSorted(compareActivitiesByOrder); for (const activity of ordered) { const payload = @@ -105,9 +155,14 @@ export function derivePendingApprovals( ? ApprovalRequestId.makeUnsafe(payload.requestId) : null; const requestKind = - payload && (payload.requestKind === "command" || payload.requestKind === "file-change") + payload && + (payload.requestKind === "command" || + payload.requestKind === "file-read" || + payload.requestKind === "file-change") ? payload.requestKind - : null; + : payload + ? requestKindFromRequestType(payload.requestType) + : null; const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { @@ -122,6 +177,16 @@ export function derivePendingApprovals( if (activity.kind === "approval.resolved" && requestId) { openByRequestId.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.approval.respond.failed" && + requestId && + detail?.includes("Unknown pending permission request") + ) { + openByRequestId.delete(requestId); + continue; } } @@ -130,13 +195,202 @@ export function derivePendingApprovals( ); } +function parseUserInputQuestions( + payload: Record | null, +): ReadonlyArray | null { + const questions = payload?.questions; + if (!Array.isArray(questions)) { + return null; + } + const parsed = questions + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const question = entry as Record; + if ( + typeof question.id !== "string" || + typeof question.header !== "string" || + typeof question.question !== "string" || + !Array.isArray(question.options) + ) { + return null; + } + const options = question.options + .map((option) => { + if (!option || typeof option !== "object") return null; + const optionRecord = option as Record; + if ( + typeof optionRecord.label !== "string" || + typeof optionRecord.description !== "string" + ) { + return null; + } + return { + label: optionRecord.label, + description: optionRecord.description, + }; + }) + .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (options.length === 0) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + }; + }) + .filter((question): question is UserInputQuestion => question !== null); + return parsed.length > 0 ? parsed : null; +} + +export function derivePendingUserInputs( + activities: ReadonlyArray, +): PendingUserInput[] { + const openByRequestId = new Map(); + const ordered = [...activities].toSorted(compareActivitiesByOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = + payload && typeof payload.requestId === "string" + ? ApprovalRequestId.makeUnsafe(payload.requestId) + : null; + + if (activity.kind === "user-input.requested" && requestId) { + const questions = parseUserInputQuestions(payload); + if (!questions) { + continue; + } + openByRequestId.set(requestId, { + requestId, + createdAt: activity.createdAt, + questions, + }); + continue; + } + + if (activity.kind === "user-input.resolved" && requestId) { + openByRequestId.delete(requestId); + } + } + + return [...openByRequestId.values()].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const candidates = ordered.filter((activity) => { + if (activity.kind !== "turn.plan.updated") { + return false; + } + if (!latestTurnId) { + return true; + } + return activity.turnId === latestTurnId; + }); + const latest = candidates.at(-1); + if (!latest) { + return null; + } + const payload = + latest.payload && typeof latest.payload === "object" + ? (latest.payload as Record) + : null; + const rawPlan = payload?.plan; + if (!Array.isArray(rawPlan)) { + return null; + } + const steps = rawPlan + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const record = entry as Record; + if (typeof record.step !== "string") { + return null; + } + const status = + record.status === "completed" || record.status === "inProgress" + ? record.status + : "pending"; + return { + step: record.step, + status, + }; + }) + .filter( + ( + step, + ): step is { + step: string; + status: "pending" | "inProgress" | "completed"; + } => step !== null, + ); + if (steps.length === 0) { + return null; + } + return { + createdAt: latest.createdAt, + turnId: latest.turnId, + ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), + steps, + }; +} + +export function findLatestProposedPlan( + proposedPlans: ReadonlyArray, + latestTurnId: TurnId | string | null | undefined, +): LatestProposedPlanState | null { + if (latestTurnId) { + const matchingTurnPlan = [...proposedPlans] + .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) + .toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), + ) + .at(-1); + if (matchingTurnPlan) { + return { + id: matchingTurnPlan.id, + createdAt: matchingTurnPlan.createdAt, + updatedAt: matchingTurnPlan.updatedAt, + turnId: matchingTurnPlan.turnId, + planMarkdown: matchingTurnPlan.planMarkdown, + }; + } + } + + const latestPlan = [...proposedPlans] + .toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), + ) + .at(-1); + if (!latestPlan) { + return null; + } + + return { + id: latestPlan.id, + createdAt: latestPlan.createdAt, + updatedAt: latestPlan.updatedAt, + turnId: latestPlan.turnId, + planMarkdown: latestPlan.planMarkdown, + }; +} + export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, ): WorkLogEntry[] { - const ordered = [...activities].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); + const ordered = [...activities].toSorted(compareActivitiesByOrder); return ordered .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) .filter((activity) => activity.kind !== "tool.started") @@ -159,6 +413,23 @@ export function deriveWorkLogEntries( }); } +function compareActivitiesByOrder( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if (left.sequence !== undefined && right.sequence !== undefined) { + if (left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + } else if (left.sequence !== undefined) { + return 1; + } else if (right.sequence !== undefined) { + return -1; + } + + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + export function hasToolActivityForTurn( activities: ReadonlyArray, turnId: TurnId | null | undefined, @@ -169,6 +440,7 @@ export function hasToolActivityForTurn( export function deriveTimelineEntries( messages: ChatMessage[], + proposedPlans: ProposedPlan[], workEntries: WorkLogEntry[], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ @@ -177,13 +449,21 @@ export function deriveTimelineEntries( createdAt: message.createdAt, message, })); + const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ + id: proposedPlan.id, + kind: "proposed-plan", + createdAt: proposedPlan.createdAt, + proposedPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } export function inferCheckpointTurnCountByTurnId( diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 2b29dd8ff8..1b279eea57 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,8 +1,8 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, type AppState } from "./store"; -import type { Thread } from "./types"; +import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { return { @@ -11,10 +11,13 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], turnDiffSummaries: [], activities: [], + proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", latestTurn: null, @@ -38,7 +41,49 @@ function makeState(thread: Thread): AppState { ], threads: [thread], threadsHydrated: true, - runtimeMode: "full-access", + }; +} + +function makeReadModelThread(overrides: Partial) { + return { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5.3-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: null, + ...overrides, + } satisfies OrchestrationReadModel["threads"][number]; +} + +function makeReadModel(thread: OrchestrationReadModel["threads"][number]): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: "2026-02-27T00:00:00.000Z", + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: "gpt-5.3-codex", + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + scripts: [], + }, + ], + threads: [thread], }; } @@ -82,3 +127,63 @@ describe("store pure functions", () => { expect(next).toEqual(initialState); }); }); + +describe("store read model sync", () => { + it("preserves claude model slugs without an active session", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "claude-opus-4-6", + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); + }); + + it("resolves claude aliases when session provider is claudeCode", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "sonnet", + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-02-27T00:00:00.000Z", + }, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); + }); + + it("resolves cursor aliases when session provider is cursor", () => { + const initialState = makeState(makeThread()); + const readModel = makeReadModel( + makeReadModelThread({ + model: "composer", + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "cursor", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-02-27T00:00:00.000Z", + }, + }), + ); + + const next = syncServerReadModel(initialState, readModel); + + expect(next.threads[0]?.model).toBe("composer-1.5"); + expect(next.threads[0]?.session?.provider).toBe("cursor"); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e761bc30d4..3fa35562d7 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,18 +1,21 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { DEFAULT_MODEL, - ProviderSessionId, + type ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, - resolveModelSlug, } from "@t3tools/contracts"; +import { + getModelOptions, + normalizeModelSlug, + resolveModelSlug, + resolveModelSlugForProvider, +} from "@t3tools/shared/model"; import { create } from "zustand"; import { - DEFAULT_RUNTIME_MODE, type ChatMessage, type Project, - type RuntimeMode, type Thread, } from "./types"; @@ -22,10 +25,9 @@ export interface AppState { projects: Project[]; threads: Thread[]; threadsHydrated: boolean; - runtimeMode: RuntimeMode; } -const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; +const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v6", "t3code:renderer-state:v5", @@ -41,7 +43,6 @@ const initialState: AppState = { projects: [], threads: [], threadsHydrated: false, - runtimeMode: DEFAULT_RUNTIME_MODE, }; const persistedExpandedProjectCwds = new Set(); @@ -52,23 +53,14 @@ function readPersistedState(): AppState { try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; - const parsed = JSON.parse(raw) as { - runtimeMode?: RuntimeMode; - expandedProjectCwds?: string[]; - }; + const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] }; persistedExpandedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } - return { - ...initialState, - runtimeMode: - parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" - ? parsed.runtimeMode - : DEFAULT_RUNTIME_MODE, - }; + return { ...initialState }; } catch { return initialState; } @@ -80,7 +72,6 @@ function persistState(state: AppState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ - runtimeMode: state.runtimeMode, expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), @@ -153,8 +144,55 @@ function toLegacySessionStatus( } } -function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { - return providerName === "claudeCode" ? "claudeCode" : "codex"; +function toLegacyProvider(providerName: string | null): ProviderKind { + if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { + return providerName; + } + return "codex"; +} + +const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CLAUDE_MODEL_SLUGS = new Set( + getModelOptions("claudeCode").map((option) => option.slug), +); +const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); +const CURSOR_DISTINCT_MODEL_SLUGS = new Set( + [...CURSOR_MODEL_SLUGS].filter( + (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), + ), +); + +function inferProviderForThreadModel(input: { + readonly model: string; + readonly sessionProviderName: string | null; +}): ProviderKind { + if ( + input.sessionProviderName === "codex" || + input.sessionProviderName === "claudeCode" || + input.sessionProviderName === "cursor" + ) { + return input.sessionProviderName; + } + const normalizedCursor = normalizeModelSlug(input.model, "cursor"); + if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) { + return "cursor"; + } + const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); + if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { + return "claudeCode"; + } + const normalizedCodex = normalizeModelSlug(input.model, "codex"); + if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { + return "codex"; + } + if ( + input.model.trim().startsWith("composer-") || + input.model.trim().startsWith("gemini-") || + input.model.trim().endsWith("-thinking") + ) { + return "cursor"; + } + return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex"; } function resolveWsHttpOrigin(): string { @@ -212,19 +250,23 @@ export function syncServerReadModel( const existing = existingThreadById.get(thread.id); return { id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, + codexThreadId: null, projectId: thread.projectId, title: thread.title, - model: resolveModelSlug(thread.model), + model: resolveModelSlugForProvider( + inferProviderForThreadModel({ + model: thread.model, + sessionProviderName: thread.session?.providerName ?? null, + }), + thread.model, + ), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, session: thread.session ? { - sessionId: - thread.session.providerSessionId ?? - ProviderSessionId.makeUnsafe(`thread:${thread.id}`), provider: toLegacyProvider(thread.session.providerName), status: toLegacySessionStatus(thread.session.status), orchestrationStatus: thread.session.status, - threadId: thread.session.providerThreadId, activeTurnId: thread.session.activeTurnId ?? undefined, createdAt: thread.session.updatedAt, updatedAt: thread.session.updatedAt, @@ -251,6 +293,13 @@ export function syncServerReadModel( }; return normalizedMessage; }), + proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ + id: proposedPlan.id, + turnId: proposedPlan.turnId, + planMarkdown: proposedPlan.planMarkdown, + createdAt: proposedPlan.createdAt, + updatedAt: proposedPlan.updatedAt, + })), error: thread.session?.lastError ?? null, createdAt: thread.createdAt, latestTurn: thread.latestTurn, @@ -360,11 +409,6 @@ export function setThreadBranch( return threads === state.threads ? state : { ...state, threads }; } -export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState { - if (state.runtimeMode === mode) return state; - return { ...state, runtimeMode: mode }; -} - // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { @@ -379,7 +423,6 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; - setRuntimeMode: (mode: RuntimeMode) => void; } export const useStore = create((set) => ({ @@ -398,11 +441,9 @@ export const useStore = create((set) => ({ set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), - setRuntimeMode: (mode) => - set((state) => setRuntimeMode(state, mode)), })); -// Persist on every state change (only runtimeMode + expandedProjectCwds) +// Persist on every state change useStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 214b27cb4f..d5fff12991 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,5 +1,6 @@ import type { OrchestrationLatestTurn, + OrchestrationProposedPlanId, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -8,14 +9,15 @@ import type { TurnId, MessageId, CheckpointRef, - ProviderThreadId, - ProviderSessionId, ProviderKind, + ProviderInteractionMode, + RuntimeMode, } from "@t3tools/contracts"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; -export type RuntimeMode = "approval-required" | "full-access"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; + +export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; export const DEFAULT_THREAD_TERMINAL_ID = "default"; export const MAX_THREAD_TERMINAL_COUNT = 4; @@ -47,6 +49,14 @@ export interface ChatMessage { streaming: boolean; } +export interface ProposedPlan { + id: OrchestrationProposedPlanId; + turnId: TurnId | null; + planMarkdown: string; + createdAt: string; + updatedAt: string; +} + export interface TurnDiffFileChange { path: string; kind?: string | undefined; @@ -75,12 +85,15 @@ export interface Project { export interface Thread { id: ThreadId; - codexThreadId: ProviderThreadId | null; + codexThreadId: string | null; projectId: ProjectId; title: string; model: string; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; session: ThreadSession | null; messages: ChatMessage[]; + proposedPlans: ProposedPlan[]; error: string | null; createdAt: string; latestTurn: OrchestrationLatestTurn | null; @@ -92,10 +105,8 @@ export interface Thread { } export interface ThreadSession { - sessionId: ProviderSessionId; provider: ProviderKind; status: SessionPhase | "error" | "closed"; - threadId: ProviderThreadId | null; activeTurnId?: TurnId | undefined; createdAt: string; updatedAt: string; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index a73b7cd4eb..516df6046a 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,7 +1,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import type { Thread } from "./types"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; function makeThread(overrides: Partial = {}): Thread { @@ -11,10 +11,13 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5.3-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], turnDiffSummaries: [], activities: [], + proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index d2329a041d..142174fb01 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -6,6 +6,7 @@ import { ProjectId, ThreadId, WS_CHANNELS, + WS_METHODS, type ServerProviderStatus, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -330,6 +331,24 @@ describe("wsNativeApi", () => { }); }); + it("forwards workspace file writes to the websocket project method", async () => { + requestMock.mockResolvedValue({ relativePath: "plan.md" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.projects.writeFile({ + cwd: "/tmp/project", + relativePath: "plan.md", + contents: "# Plan\n", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, { + cwd: "/tmp/project", + relativePath: "plan.md", + contents: "# Plan\n", + }); + }); + it("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f3bdfe5d07..91e6a61107 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -141,6 +141,7 @@ export function createWsNativeApi(): NativeApi { }, projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), + writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/bun.lock b/bun.lock index 585977968f..f4e52bd2ac 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", @@ -121,6 +122,7 @@ "name": "@t3tools/shared", "version": "0.0.0-alpha.1", "dependencies": { + "@t3tools/contracts": "workspace:*", "effect": "catalog:", }, "devDependencies": { @@ -161,13 +163,15 @@ "vitest": "^4.0.0", }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.70", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-ABaB37jWt7dZXfIDHebHv99jX9GIyqc0aSjcz9nxq79eauOpa+64Cah5hx/yzhsWz7m5GEtjbMIZCClTfnRRhg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@8.0.0-rc.1", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.1", "@babel/types": "^8.0.0-rc.1", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w=="], + "@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], @@ -181,13 +185,13 @@ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.1", "", {}, "sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@8.0.0-rc.1", "", { "dependencies": { "@babel/types": "^8.0.0-rc.1" }, "bin": "./bin/babel-parser.js" }, "sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA=="], + "@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], @@ -279,15 +283,15 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@floating-ui/react": ["@floating-ui/react@0.27.18", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.7", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q=="], + "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], @@ -297,10 +301,42 @@ "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="], - "@hapi/tlds": ["@hapi/tlds@1.1.5", "", {}, "sha512-Vq/1gnIIsvFUpKlDdfrPd/ssHDpAyBP/baVukh3u2KSG2xoNjsnRNjQiPmuyPPGqsn1cqVWWhtZHfOBaLizFRQ=="], + "@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="], "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], @@ -311,7 +347,7 @@ "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - "@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -389,7 +425,7 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.112.0", "", {}, "sha512-4vYtWXMnXM6EaweCxbJ6bISAhkNHeN33SihvuX3wrpqaSJA4ZEoW35i9mSvE74+GDf1yTeVE+aEHA+WBpjDk/g=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], @@ -431,45 +467,47 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.50.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.51.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.50.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.51.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.50.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.51.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.50.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.51.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.51.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.51.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.51.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.51.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.50.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.51.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.51.0", "", { "os": "linux", "cpu": "none" }, "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.51.0", "", { "os": "linux", "cpu": "none" }, "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.50.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.51.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.51.0", "", { "os": "linux", "cpu": "x64" }, "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.51.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.50.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.51.0", "", { "os": "none", "cpu": "arm64" }, "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.50.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.51.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.50.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.51.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + + "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -491,6 +529,10 @@ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y48ShVxGE2zUTt0A0PR3grCLNxW4DWtAfe5lxf6L3uYEQujwo/LGuRogMsAtOJeYLCPTJo2i714LOdnK34cHpw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-KU5DUYvX3qI8/TX6D3RA4awXi4Ge/1+M6Jqv7kRiUndpqoVGgD765xhV3Q6QvtABnYjLJenrWDl3S1B5U56ixA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.3", "", { "os": "linux", "cpu": "x64" }, "sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ=="], "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.3", "", { "os": "linux", "cpu": "x64" }, "sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw=="], @@ -505,69 +547,69 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], - "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "@shikijs/transformers": ["@shikijs/transformers@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/types": "3.22.0" } }, "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw=="], + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], - "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -587,39 +629,39 @@ "@t3tools/web": ["@t3tools/web@workspace:apps/web"], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], - "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.1", "", {}, "sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg=="], - "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], @@ -629,25 +671,25 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], - "@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="], + "@tanstack/react-router": ["@tanstack/react-router@1.166.2", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.2", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-pKhUtrvVLlhjWhsHkJSuIzh1J4LcP+8ErbIqRLORX9Js8dUFMKoT0+8oFpi+P8QRpuhm/7rzjYiWfcyTsqQZtA=="], "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.20", "", { "dependencies": { "@tanstack/virtual-core": "3.13.20" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LkFkwMMKKJ0zcbN4s37hTbj+22sHCXpaqV6+kh2x74aFg59kKCfdeeKtvCETVw8vP8oqtoWo+v+3JWkuGLqF/A=="], - "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="], + "@tanstack/router-core": ["@tanstack/router-core@1.166.2", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-zn3NhENOAX9ToQiX077UV2OH3aJKOvV2ZMNZZxZ3gDG3i3WqL8NfWfEgetEAfMN37/Mnt90PpotYgf7IyuoKqQ=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.160.1", "", { "dependencies": { "@tanstack/router-core": "1.160.0", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-De6TicInwy3/9rQ++RZAyFOvB2oi5UV5T0iiIlxe3jgiOLFxMA4EKKVlT+alDxKnq6udTLam9xqhvGOVZ6a2hw=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.2", "", { "dependencies": { "@tanstack/router-core": "1.166.2", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-wbvdyP1PKKQKk4aVlGeK9S5uDy8zodTr3tEZ2gRKNavJLusXbEWqtoo42JxHFFNB6dtguehFMt8PyZPAtkgWwQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.161.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.160.0", "@tanstack/router-generator": "1.160.1", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.160.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-E0JGeMdKGYYMMjOEiM+330nIs0PG3nE/9KyU+7+SV54hDf4Xdt6BC3J9pDj1qA3oNqI5DwwTsG7GBA+7m47O4A=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.166.2", "@tanstack/router-generator": "1.166.2", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.166.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-TnyV/7//Vp5fR49mmNbOWHGz9IJTm1lqVxzPdtpzg7D5PjkW2HFmLFLtWwpJgz2R7AJJWR4Ge5kIPmC+fVZ6eQ=="], - "@tanstack/router-utils": ["@tanstack/router-utils@1.158.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="], - "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.20", "", {}, "sha512-Djnq7ujPWcRAKyDpwqL4JDe6ZTN9AWAqE2wLstBlsEu4OnO7Im0p8KsHzLU7TPIvLQgNKpkn9EmgBH6xs8yjfA=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -659,7 +701,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -685,7 +727,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -727,7 +769,7 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -747,7 +789,7 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -755,7 +797,7 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -771,7 +813,7 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -783,7 +825,7 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -869,7 +911,7 @@ "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], "electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], @@ -879,7 +921,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -957,7 +999,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -991,7 +1033,7 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "ioredis": ["ioredis@5.9.3", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1019,7 +1061,7 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], @@ -1107,7 +1149,7 @@ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], @@ -1223,7 +1265,7 @@ "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1245,7 +1287,7 @@ "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], - "oxlint": ["oxlint@1.50.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.50.0", "@oxlint/binding-android-arm64": "1.50.0", "@oxlint/binding-darwin-arm64": "1.50.0", "@oxlint/binding-darwin-x64": "1.50.0", "@oxlint/binding-freebsd-x64": "1.50.0", "@oxlint/binding-linux-arm-gnueabihf": "1.50.0", "@oxlint/binding-linux-arm-musleabihf": "1.50.0", "@oxlint/binding-linux-arm64-gnu": "1.50.0", "@oxlint/binding-linux-arm64-musl": "1.50.0", "@oxlint/binding-linux-ppc64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-musl": "1.50.0", "@oxlint/binding-linux-s390x-gnu": "1.50.0", "@oxlint/binding-linux-x64-gnu": "1.50.0", "@oxlint/binding-linux-x64-musl": "1.50.0", "@oxlint/binding-openharmony-arm64": "1.50.0", "@oxlint/binding-win32-arm64-msvc": "1.50.0", "@oxlint/binding-win32-ia32-msvc": "1.50.0", "@oxlint/binding-win32-x64-msvc": "1.50.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ=="], + "oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], @@ -1269,7 +1311,7 @@ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -1281,7 +1323,7 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], @@ -1337,9 +1379,9 @@ "rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], - "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.1", "", { "dependencies": { "@babel/generator": "8.0.0-rc.1", "@babel/helper-validator-identifier": "8.0.0-rc.1", "@babel/parser": "8.0.0-rc.1", "@babel/types": "8.0.0-rc.1", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.1", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-5E0AiM5RSQhU6cjtkDFWH6laW4IrMu0j1Mo8x04Xo1ALHmaRMs9/7zej7P3RrryVHW/DdZAp85MA7Be55p0iUw=="], + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.3", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-APIGZGChvLVu05f+7bMmgf+lpvhjIvELhkOsg7c/95IVdOgULVFOX9iciaHJLaBfZeTthIgp+gryGBjgyKNA1A=="], - "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1359,7 +1401,7 @@ "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], - "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -1403,9 +1445,9 @@ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1425,9 +1467,9 @@ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + "tldts": ["tldts@7.0.24", "", { "dependencies": { "tldts-core": "^7.0.24" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ=="], - "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + "tldts-core": ["tldts-core@7.0.24", "", {}, "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1449,25 +1491,25 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="], + "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], - "turbo-linux-64": ["turbo-linux-64@2.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q=="], + "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], - "turbo-windows-64": ["turbo-windows-64@2.8.7", "", { "os": "win32", "cpu": "x64" }, "sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg=="], + "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], @@ -1489,7 +1531,7 @@ "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="], + "unrun": ["unrun@0.2.30", "", { "dependencies": { "rolldown": "1.0.0-rc.7" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-a4W1wDADI0gvDDr14T0ho1FgMhmfjq6M8Iz8q234EnlxgH/9cMHDueUSLwTl1fwSBs5+mHrLFYH+7B8ao36EBA=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], @@ -1503,7 +1545,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.0.0-beta.13", "", { "dependencies": { "@oxc-project/runtime": "0.112.0", "fdir": "^6.5.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.3", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.24", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7s/rfpYOAo7WUHh9irzaGjhhKb12hGv0BpDegAMV5A391wdyvM45WtX6VMV7hvEtZF2j/QtpDpR6ldXI3GgARQ=="], + "vite": ["vite@8.0.0-beta.16", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.6", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q=="], "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], @@ -1553,13 +1595,13 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], + "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], + "@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "@babel/template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], @@ -1569,12 +1611,18 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@effect/platform-node/effect": ["effect@4.0.0-beta.27", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-bNQEF3vaVGF8jAJ+HW1A4DaxVNmujgEu89sO+VNW1AvUYtPGMtKPTBU15K9inu1rG+hkxNFFRlVvLAJsdaE2mg=="], + + "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.27", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-bNQEF3vaVGF8jAJ+HW1A4DaxVNmujgEu89sO+VNW1AvUYtPGMtKPTBU15K9inu1rG+hkxNFFRlVvLAJsdaE2mg=="], + + "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.27", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-bNQEF3vaVGF8jAJ+HW1A4DaxVNmujgEu89sO+VNW1AvUYtPGMtKPTBU15K9inu1rG+hkxNFFRlVvLAJsdaE2mg=="], + + "@effect/vitest/effect": ["effect@4.0.0-beta.27", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-bNQEF3vaVGF8jAJ+HW1A4DaxVNmujgEu89sO+VNW1AvUYtPGMtKPTBU15K9inu1rG+hkxNFFRlVvLAJsdaE2mg=="], + "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1589,6 +1637,12 @@ "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "@tanstack/pacer/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + + "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], @@ -1597,16 +1651,6 @@ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - "@types/cacheable-request/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], - - "@types/keyv/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], - - "@types/responselike/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], - - "@types/ws/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], - - "@types/yauzl/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], - "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1627,7 +1671,7 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], + "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1635,55 +1679,87 @@ "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unrun/rolldown": ["rolldown@1.0.0-rc.7", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.7" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.7", "@rolldown/binding-darwin-arm64": "1.0.0-rc.7", "@rolldown/binding-darwin-x64": "1.0.0-rc.7", "@rolldown/binding-freebsd-x64": "1.0.0-rc.7", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.7", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.7", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.7", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.7", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.7", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.7", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.7", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.7", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.7", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.7", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.7" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-5X0zEeQFzDpB3MqUWQZyO2TUQqP9VnT7CqXHF2laTFRy487+b6QZyotCazOySAuZLAvplCaOVsg1tVn/Zlmwfg=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/rolldown": ["rolldown@1.0.0-rc.6", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.6" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-x64": "1.0.0-rc.6", "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw=="], + "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], - "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "@tailwindcss/vite/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], - "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.7", "", { "os": "android", "cpu": "arm64" }, "sha512-/uadfNUaMLFFBGvcIOiq8NnlhvTZTjOyybJaJnhGxD0n9k5vZRJfTaitH5GHnbwmc6T2PC+ZpS1FQH+vXyS/UA=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "unrun/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zokYr1KgRn0hRA89dmgtPj/BmKp9DxgrfAJvOEFfXa8nfYWW2nmgiYIBGpSIAJrEg7Qc/Qznovy6xYwmKh0M8g=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "unrun/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-eZFjbmrapCBVgMmuLALH3pmQQQStHFuRhsFceJHk6KISW8CkI2e9OPLp9V4qXksrySQcD8XM8fpvGLs5l5C7LQ=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "unrun/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xjMrh8Dmu2DNwdY6DZsrF6YPGeesc3PaTlkh8v9cqmkSCNeTxnhX3ErhVnuv1j3n8t2IuuhQIwM9eZDINNEt5Q=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "unrun/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.7", "", { "os": "linux", "cpu": "arm" }, "sha512-mOvftrHiXg4/xFdxJY3T9Wl1/zDAOSlMN8z9an2bXsCwuvv3RdyhYbSMZDuDO52S04w9z7+cBd90lvQSPTAQtw=="], - "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-TuUkeuEEPRyXMBbJ86NRhAiPNezxHW8merl3Om2HASA9Pl1rI+VZcTtsVQ6v/P0MDIFpSl0k0+tUUze9HIXyEw=="], - "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "unrun/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-G43ZElEvaby+YSOgrXfBgpeQv42LdS0ivFFYQufk2tBDWeBfzE/+ob5DmO8Izbyn4Y8k6GgLF11jFDYNnmU/3w=="], - "@tailwindcss/vite/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unrun/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1THb6FdBkAEL12zvUue2bmK4W1+P+tz8Pgu5uEzq+xrtYa3iBzmmKNlyfUzCFNCqsPd8WJEQrYdLcw4iMW4AVw=="], - "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unrun/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.7", "", { "os": "linux", "cpu": "x64" }, "sha512-12o73atFNWDgYnLyA52QEUn9AH8pHIe12W28cmqjyHt4bIEYRzMICvYVCPa2IQm6DJBvCBrEhD9K+ct4wr2hwg=="], - "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unrun/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+uUgGwvuUCXl894MTsmTS2J0BnCZccFsmzV7y1jFxW5pTSxkuwL5agyPuDvDOztPeS6RrdqWkn7sT0jRd0ECkg=="], - "@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unrun/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.7", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-53p2L/NSy21UiFOqUGlC11kJDZS2Nx2GJRz1QvbkXovypA3cOHbsyZHLkV72JsLSbiEQe+kg4tndUhSiC31UEA=="], - "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unrun/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-K6svNRljO6QrL6VTKxwh4yThhlR9DT/tK0XpaFQMnJwwQKng+NYcVEtUkAM0WsoiZHw+Hnh3DGnn3taf/pNYGg=="], - "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unrun/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.7", "", { "os": "win32", "cpu": "x64" }, "sha512-3ZJBT47VWLKVKIyvHhUSUgVwHzzZW761YAIkM3tOT+8ZTjFVp0acCM0Y2Z2j3jCl+XYi2d9y2uEWQ8H0PvvpPw=="], - "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unrun/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], + + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.6", "", { "os": "android", "cpu": "arm64" }, "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm" }, "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.6", "", { "os": "none", "cpu": "arm64" }, "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ=="], + + "vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.6", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "x64" }, "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA=="], - "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.6", "", {}, "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA=="], "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } diff --git a/docs/adr/0001-provider-runtime-lifecycle-ownership.md b/docs/adr/0001-provider-runtime-lifecycle-ownership.md deleted file mode 100644 index 1c5afa5093..0000000000 --- a/docs/adr/0001-provider-runtime-lifecycle-ownership.md +++ /dev/null @@ -1,40 +0,0 @@ -# ADR 0001: Provider Runtime Lifecycle Ownership - -## Status -Accepted - -## Context -Provider runtime streams can include auxiliary work (for example collab/child-agent turns) under the same provider session as a user-visible primary turn. - -The previous orchestration ingestion and checkpoint flows keyed lifecycle updates by `providerSessionId` alone. This allowed auxiliary `turn.completed` events to: - -- mark thread sessions as `ready` before the primary turn completed -- clear `activeTurnId` for a still-running primary turn -- emit checkpoint turn-diff summaries for auxiliary turns in the main thread timeline - -## Decision -Define and enforce a lifecycle ownership invariant: - -- Only events in the primary thread/turn lane may mutate thread session lifecycle state (`status`, `activeTurnId`, `providerThreadId`) and checkpoint turn completion. -- Auxiliary events may still append messages and activities. - -Current enforcement is implemented with scope guards: - -- Runtime ingestion only applies lifecycle transitions when the event targets the active primary scope (provider thread and active turn checks). -- Checkpoint capture ignores `turn.completed` events from non-primary provider thread scope, and ignores non-active turn completions while a primary turn is active. - -## Consequences - -### Positive -- Prevents premature loss of "working" state from auxiliary turn completion. -- Prevents auxiliary changed-files/checkpoint cards from appearing as if they were primary turn completion. -- Keeps useful auxiliary activity/message visibility. - -### Negative -- Runtime events that omit thread/turn identifiers are treated conservatively in lifecycle transitions. -- Full explicit lane modeling is still desirable in contracts for long-term clarity. - -## Follow-up -1. Add explicit runtime lane metadata (`primary` vs `auxiliary`) to canonical provider runtime events. -2. Persist orchestration-to-provider turn binding (`TurnId` <-> `ProviderTurnId`) for stronger reconciliation and replay safety. -3. Promote lifecycle ownership checks from heuristic guards to schema-level invariants once lane metadata is available. diff --git a/package.json b/package.json index 907b6fde1e..5709126214 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test": "turbo run test", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", + "probe:cursor-acp": "node scripts/cursor-acp-probe.mjs", "build:contracts": "turbo run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 52fa5d9e66..24962aed69 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -28,14 +28,16 @@ export type MessageId = typeof MessageId.Type; export const TurnId = makeEntityId("TurnId"); export type TurnId = typeof TurnId.Type; -export const ProviderSessionId = makeEntityId("ProviderSessionId"); -export type ProviderSessionId = typeof ProviderSessionId.Type; -export const ProviderThreadId = makeEntityId("ProviderThreadId"); -export type ProviderThreadId = typeof ProviderThreadId.Type; -export const ProviderTurnId = makeEntityId("ProviderTurnId"); -export type ProviderTurnId = typeof ProviderTurnId.Type; export const ProviderItemId = makeEntityId("ProviderItemId"); export type ProviderItemId = typeof ProviderItemId.Type; +export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); +export type RuntimeSessionId = typeof RuntimeSessionId.Type; +export const RuntimeItemId = makeEntityId("RuntimeItemId"); +export type RuntimeItemId = typeof RuntimeItemId.Type; +export const RuntimeRequestId = makeEntityId("RuntimeRequestId"); +export type RuntimeRequestId = typeof RuntimeRequestId.Type; +export const RuntimeTaskId = makeEntityId("RuntimeTaskId"); +export type RuntimeTaskId = typeof RuntimeTaskId.Type; export const ApprovalRequestId = makeEntityId("ApprovalRequestId"); export type ApprovalRequestId = typeof ApprovalRequestId.Type; export const CheckpointRef = makeEntityId("CheckpointRef"); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 99738b7ac0..80ede248e6 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -7,20 +7,19 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); export type GitStackedAction = typeof GitStackedAction.Type; -export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); -export const GitPushStepStatus = Schema.Literals([ +const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); +const GitPushStepStatus = Schema.Literals([ "pushed", "skipped_not_requested", "skipped_up_to_date", ]); -export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); -export const GitPrStepStatus = Schema.Literals([ +const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); +const GitPrStepStatus = Schema.Literals([ "created", "opened_existing", "skipped_not_requested", ]); -export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); -export type GitStatusPrState = typeof GitStatusPrState.Type; +const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, @@ -32,11 +31,10 @@ export const GitBranch = Schema.Struct({ }); export type GitBranch = typeof GitBranch.Type; -export const GitWorktree = Schema.Struct({ +const GitWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, }); -export type GitWorktree = typeof GitWorktree.Type; // RPC Inputs @@ -97,7 +95,7 @@ export type GitInitInput = typeof GitInitInput.Type; // RPC Results -export const GitStatusPr = Schema.Struct({ +const GitStatusPr = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, url: Schema.String, @@ -105,7 +103,6 @@ export const GitStatusPr = Schema.Struct({ headBranch: TrimmedNonEmptyStringSchema, state: GitStatusPrState, }); -export type GitStatusPr = typeof GitStatusPr.Type; export const GitStatusResult = Schema.Struct({ branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bf129533c5..92f5b502c9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -14,7 +14,12 @@ import type { GitStatusInput, GitStatusResult, } from "./git"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; +import type { + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "./project"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -104,6 +109,7 @@ export interface NativeApi { }; projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; + writeFile: (input: ProjectWriteFileInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index a6e7e9bdeb..48821b1824 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,15 +1,13 @@ import { Schema } from "effect"; import { TrimmedString } from "./baseSchemas"; -export const MAX_KEYBINDING_COMMAND_LENGTH = 96; export const MAX_KEYBINDING_VALUE_LENGTH = 64; -export const MAX_KEYBINDING_WHEN_LENGTH = 256; +const MAX_KEYBINDING_WHEN_LENGTH = 256; export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; -export const MAX_SCRIPT_RUN_COMMAND_LENGTH = 96; export const MAX_KEYBINDINGS_COUNT = 256; -export const STATIC_KEYBINDING_COMMANDS = [ +const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", "terminal.new", @@ -35,17 +33,15 @@ export const KeybindingCommand = Schema.Union([ ]); export type KeybindingCommand = typeof KeybindingCommand.Type; -export const KeybindingValue = TrimmedString.check( +const KeybindingValue = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), ); -export type KeybindingValue = typeof KeybindingValue.Type; -export const KeybindingWhen = TrimmedString.check( +const KeybindingWhen = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), ); -export type KeybindingWhen = typeof KeybindingWhen.Type; export const KeybindingRule = Schema.Struct({ key: KeybindingValue, command: KeybindingCommand, diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts deleted file mode 100644 index 2f31b6ebe3..0000000000 --- a/packages/contracts/src/model.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_MODEL, MODEL_OPTIONS, normalizeModelSlug, resolveModelSlug } from "./model"; - -describe("normalizeModelSlug", () => { - it("maps known aliases to canonical slugs", () => { - expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); - }); - - it("returns null for empty or missing values", () => { - expect(normalizeModelSlug("")).toBeNull(); - expect(normalizeModelSlug(" ")).toBeNull(); - expect(normalizeModelSlug(null)).toBeNull(); - expect(normalizeModelSlug(undefined)).toBeNull(); - }); - - it("preserves non-aliased model slugs", () => { - expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); - expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); -}); - -describe("resolveModelSlug", () => { - it("returns default only when the model is missing", () => { - expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); - expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); - }); - - it("preserves unknown custom models", () => { - expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1"); - expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); - }); - - it("resolves only supported model options", () => { - for (const model of MODEL_OPTIONS) { - expect(resolveModelSlug(model.slug)).toBe(model.slug); - } - }); -}); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index cf019e98bd..6f88a37d24 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,40 +1,157 @@ -export const MODEL_OPTIONS = [ - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { slug: "gpt-5.2", name: "GPT-5.2" }, -] as const; +import { Schema } from "effect"; +import type { ProviderKind } from "./orchestration"; + +export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const; +export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; + +export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; +export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; + +export const CodexModelOptions = Schema.Struct({ + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), +}); +export type CodexModelOptions = typeof CodexModelOptions.Type; -export type BuiltInModelSlug = (typeof MODEL_OPTIONS)[number]["slug"]; -export type ModelSlug = string; +export const ClaudeCodeModelOptions = Schema.Struct({ + thinking: Schema.optional(Schema.Boolean), +}); +export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type; -export const DEFAULT_MODEL = "gpt-5.3-codex"; +export const CursorModelOptions = Schema.Struct({ + reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Boolean), +}); +export type CursorModelOptions = typeof CursorModelOptions.Type; + +export const ProviderModelOptions = Schema.Struct({ + codex: Schema.optional(CodexModelOptions), + claudeCode: Schema.optional(ClaudeCodeModelOptions), + cursor: Schema.optional(CursorModelOptions), +}); +export type ProviderModelOptions = typeof ProviderModelOptions.Type; + +type ModelOption = { + readonly slug: string; + readonly name: string; +}; -export const MODEL_SLUG_ALIASES: Record = { - "5.3": "gpt-5.3-codex", - "gpt-5.3": "gpt-5.3-codex", - "5.3-spark": "gpt-5.3-codex-spark", - "gpt-5.3-spark": "gpt-5.3-codex-spark", +type CursorModelFamilyOption = { + readonly slug: string; + readonly name: string; }; -export function normalizeModelSlug(model: string | null | undefined): ModelSlug | null { - if (typeof model !== "string") { - return null; - } +export const CURSOR_MODEL_FAMILY_OPTIONS = [ + { slug: "auto", name: "Auto" }, + { slug: "composer-1.5", name: "Composer 1.5" }, + { slug: "composer-1", name: "Composer 1" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, + { slug: "opus-4.6", name: "Claude 4.6 Opus" }, + { slug: "opus-4.5", name: "Claude 4.5 Opus" }, + { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, +] as const satisfies readonly CursorModelFamilyOption[]; + +export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; - const trimmed = model.trim(); - if (!trimmed) { - return null; - } +export const MODEL_OPTIONS_BY_PROVIDER = { + codex: [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + ], + claudeCode: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], + cursor: [ + { slug: "auto", name: "Auto" }, + { slug: "composer-1.5", name: "Composer 1.5" }, + { slug: "composer-1", name: "Composer 1" }, + { slug: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, + { slug: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.3-codex-fast", name: "GPT-5.3 Codex Fast" }, + { slug: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, + { slug: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, + { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" }, + { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" }, + { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, + { slug: "opus-4.6", name: "Claude 4.6 Opus" }, + { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" }, + { slug: "opus-4.5", name: "Claude 4.5 Opus" }, + { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" }, + { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, + { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + ], +} as const satisfies Record; - return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; -} +type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; +export type ModelSlug = BuiltInModelSlug | (string & {}); +export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; + +export const DEFAULT_MODEL_BY_PROVIDER: Record = { + codex: "gpt-5.3-codex", + claudeCode: "claude-sonnet-4-6", + cursor: "opus-4.6-thinking", +}; + +// Backward compatibility for existing Codex-only call sites. +export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; +export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; + +export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { + codex: { + "5.4": "gpt-5.4", + "5.3": "gpt-5.3-codex", + "gpt-5.3": "gpt-5.3-codex", + "5.3-spark": "gpt-5.3-codex-spark", + "gpt-5.3-spark": "gpt-5.3-codex-spark", + }, + claudeCode: { + opus: "claude-opus-4-6", + "opus-4.6": "claude-opus-4-6", + "claude-opus-4.6": "claude-opus-4-6", + "claude-opus-4-6-20251117": "claude-opus-4-6", + sonnet: "claude-sonnet-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", + haiku: "claude-haiku-4-5", + "haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4.5": "claude-haiku-4-5", + "claude-haiku-4-5-20251001": "claude-haiku-4-5", + }, + cursor: { + composer: "composer-1.5", + "composer-1.5": "composer-1.5", + "composer-1": "composer-1", + "gpt-5.3-codex": "gpt-5.3-codex", + "gpt-5.3-codex-spark": "gpt-5.3-codex-spark-preview", + "gemini-3.1-pro": "gemini-3.1-pro", + "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking", + "claude-4.6-opus-thinking": "opus-4.6-thinking", + "claude-4.5-opus-thinking": "opus-4.5-thinking", + "sonnet-4.6-thinking": "sonnet-4.6-thinking", + "opus-4.6-thinking": "opus-4.6-thinking", + "opus-4.5-thinking": "opus-4.5-thinking", + }, +}; -export function resolveModelSlug(model: string | null | undefined): ModelSlug { - const normalized = normalizeModelSlug(model); - return normalized ?? DEFAULT_MODEL; -} +export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { + codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeCode: [], + cursor: [], +} as const satisfies Record; -export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; -export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; -export const DEFAULT_REASONING: ReasoningEffort = "high"; +export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { + codex: "high", + claudeCode: null, + cursor: null, +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29589c9e2c..4282e1076a 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1,15 +1,26 @@ -import { assert, it } from "@effect/vitest"; +import assert from "node:assert/strict"; +import { it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, OrchestrationGetTurnDiffInput, + OrchestrationSession, ProjectCreateCommand, + ThreadTurnStartCommand, ThreadTurnDiff, + ThreadTurnStartRequestedPayload, } from "./orchestration"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); +const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); +const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( + ThreadTurnStartRequestedPayload, +); +const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -84,3 +95,124 @@ it.effect("rejects command fields that become empty after trim", () => assert.strictEqual(result._tag, "Failure"); }), ); + +it.effect("decodes thread.turn.start defaults for provider and runtime mode", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-1", + threadId: "thread-1", + message: { + messageId: "msg-1", + role: "user", + text: "hello", + attachments: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); + }), +); + +it.effect("preserves explicit provider and runtime mode in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-2", + threadId: "thread-1", + message: { + messageId: "msg-2", + role: "user", + text: "hello", + attachments: [], + }, + provider: "claudeCode", + runtimeMode: "full-access", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "claudeCode"); + assert.strictEqual(parsed.runtimeMode, "full-access"); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); + }), +); + +it.effect("accepts provider-scoped model options in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-options", + threadId: "thread-1", + message: { + messageId: "msg-options", + role: "user", + text: "hello", + attachments: [], + }, + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "codex"); + assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high"); + assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true); + }), +); + +it.effect("accepts cursor provider in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-cursor", + threadId: "thread-1", + message: { + messageId: "msg-3", + role: "user", + text: "hello", + attachments: [], + }, + provider: "cursor", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "cursor"); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); + }), +); + +it.effect( + "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", + () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-1", + messageId: "msg-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); + }), +); + +it.effect("decodes orchestration session runtime mode defaults", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationSession({ + threadId: "thread-1", + status: "idle", + providerName: null, + providerSessionId: null, + providerThreadId: null, + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a6b1f82141..dd5996e967 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; +import { ProviderModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -9,9 +10,6 @@ import { NonNegativeInt, ProjectId, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -29,22 +27,16 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); +export const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; -export const ProviderApprovalPolicy = Schema.Literals([ - "untrusted", - "on-failure", - "on-request", - "never", -]); -export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; -export const ProviderSandboxMode = Schema.Literals([ - "read-only", - "workspace-write", - "danger-full-access", -]); -export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; -export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); +export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; +export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); +export type RuntimeMode = typeof RuntimeMode.Type; +export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; +export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); +export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; +export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; +export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; @@ -55,18 +47,19 @@ export const ProviderApprovalDecision = Schema.Literals([ "cancel", ]); export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; +export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); +export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; -export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; -export const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; - +const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; +const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. export const CorrelationId = CommandId; export type CorrelationId = typeof CorrelationId.Type; -export const ChatAttachmentId = TrimmedNonEmptyString.check( +const ChatAttachmentId = TrimmedNonEmptyString.check( Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS), Schema.isPattern(/^[a-z0-9_-]+$/i), ); @@ -81,7 +74,7 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; -export const UploadChatImageAttachment = Schema.Struct({ +const UploadChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), @@ -94,7 +87,7 @@ export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; export const ChatAttachment = Schema.Union([ChatImageAttachment]); export type ChatAttachment = typeof ChatAttachment.Type; -export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); +const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); export type UploadChatAttachment = typeof UploadChatAttachment.Type; export const ProjectScriptIcon = Schema.Literals([ @@ -143,6 +136,18 @@ export const OrchestrationMessage = Schema.Struct({ }); export type OrchestrationMessage = typeof OrchestrationMessage.Type; +export const OrchestrationProposedPlanId = TrimmedNonEmptyString; +export type OrchestrationProposedPlanId = typeof OrchestrationProposedPlanId.Type; + +export const OrchestrationProposedPlan = Schema.Struct({ + id: OrchestrationProposedPlanId, + turnId: Schema.NullOr(TurnId), + planMarkdown: TrimmedNonEmptyString, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationProposedPlan = typeof OrchestrationProposedPlan.Type; + export const OrchestrationSessionStatus = Schema.Literals([ "idle", "starting", @@ -158,10 +163,7 @@ export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(TrimmedNonEmptyString), - providerSessionId: Schema.NullOr(ProviderSessionId), - providerThreadId: Schema.NullOr(ProviderThreadId), - approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), - sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), updatedAt: IsoDateTime, @@ -205,11 +207,12 @@ export const OrchestrationThreadActivity = Schema.Struct({ summary: TrimmedNonEmptyString, payload: Schema.Unknown, turnId: Schema.NullOr(TurnId), + sequence: Schema.optional(NonNegativeInt), createdAt: IsoDateTime, }); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; -export const OrchestrationLatestTurnState = Schema.Literals([ +const OrchestrationLatestTurnState = Schema.Literals([ "running", "interrupted", "completed", @@ -232,6 +235,10 @@ export const OrchestrationThread = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurn: Schema.NullOr(OrchestrationLatestTurn), @@ -239,6 +246,9 @@ export const OrchestrationThread = Schema.Struct({ updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), + proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( + Schema.withDecodingDefault(() => []), + ), activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), @@ -263,7 +273,7 @@ export const ProjectCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ProjectMetaUpdateCommand = Schema.Struct({ +const ProjectMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("project.meta.update"), commandId: CommandId, projectId: ProjectId, @@ -273,31 +283,35 @@ export const ProjectMetaUpdateCommand = Schema.Struct({ scripts: Schema.optional(Schema.Array(ProjectScript)), }); -export const ProjectDeleteCommand = Schema.Struct({ +const ProjectDeleteCommand = Schema.Struct({ type: Schema.Literal("project.delete"), commandId: CommandId, projectId: ProjectId, }); -export const ThreadCreateCommand = Schema.Struct({ +const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: CommandId, threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, }); -export const ThreadDeleteCommand = Schema.Struct({ +const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), commandId: CommandId, threadId: ThreadId, }); -export const ThreadMetaUpdateCommand = Schema.Struct({ +const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, threadId: ThreadId, @@ -307,6 +321,22 @@ export const ThreadMetaUpdateCommand = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +const ThreadRuntimeModeSetCommand = Schema.Struct({ + type: Schema.Literal("thread.runtime-mode.set"), + commandId: CommandId, + threadId: ThreadId, + runtimeMode: RuntimeMode, + createdAt: IsoDateTime, +}); + +const ThreadInteractionModeSetCommand = Schema.Struct({ + type: Schema.Literal("thread.interaction-mode.set"), + commandId: CommandId, + threadId: ThreadId, + interactionMode: ProviderInteractionMode, + createdAt: IsoDateTime, +}); + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -317,15 +347,18 @@ export const ThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(ChatAttachment), }), + provider: Schema.optional(ProviderKind), model: Schema.optional(TrimmedNonEmptyString), - effort: Schema.optional(TrimmedNonEmptyString), + modelOptions: Schema.optional(ProviderModelOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - approvalPolicy: ProviderApprovalPolicy, - sandboxMode: ProviderSandboxMode, + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), createdAt: IsoDateTime, }); -export const ClientThreadTurnStartCommand = Schema.Struct({ +const ClientThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, threadId: ThreadId, @@ -335,15 +368,16 @@ export const ClientThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(UploadChatAttachment), }), + provider: Schema.optional(ProviderKind), model: Schema.optional(TrimmedNonEmptyString), - effort: Schema.optional(TrimmedNonEmptyString), + modelOptions: Schema.optional(ProviderModelOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - approvalPolicy: ProviderApprovalPolicy, - sandboxMode: ProviderSandboxMode, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, createdAt: IsoDateTime, }); -export const ThreadTurnInterruptCommand = Schema.Struct({ +const ThreadTurnInterruptCommand = Schema.Struct({ type: Schema.Literal("thread.turn.interrupt"), commandId: CommandId, threadId: ThreadId, @@ -351,7 +385,7 @@ export const ThreadTurnInterruptCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadApprovalRespondCommand = Schema.Struct({ +const ThreadApprovalRespondCommand = Schema.Struct({ type: Schema.Literal("thread.approval.respond"), commandId: CommandId, threadId: ThreadId, @@ -360,7 +394,16 @@ export const ThreadApprovalRespondCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadCheckpointRevertCommand = Schema.Struct({ +const ThreadUserInputRespondCommand = Schema.Struct({ + type: Schema.Literal("thread.user-input.respond"), + commandId: CommandId, + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + createdAt: IsoDateTime, +}); + +const ThreadCheckpointRevertCommand = Schema.Struct({ type: Schema.Literal("thread.checkpoint.revert"), commandId: CommandId, threadId: ThreadId, @@ -368,23 +411,26 @@ export const ThreadCheckpointRevertCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadSessionStopCommand = Schema.Struct({ +const ThreadSessionStopCommand = Schema.Struct({ type: Schema.Literal("thread.session.stop"), commandId: CommandId, threadId: ThreadId, createdAt: IsoDateTime, }); -export const DispatchableClientOrchestrationCommand = Schema.Union([ +const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, ThreadMetaUpdateCommand, + ThreadRuntimeModeSetCommand, + ThreadInteractionModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, + ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); @@ -398,15 +444,18 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadCreateCommand, ThreadDeleteCommand, ThreadMetaUpdateCommand, + ThreadRuntimeModeSetCommand, + ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, + ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; -export const ThreadSessionSetCommand = Schema.Struct({ +const ThreadSessionSetCommand = Schema.Struct({ type: Schema.Literal("thread.session.set"), commandId: CommandId, threadId: ThreadId, @@ -414,7 +463,7 @@ export const ThreadSessionSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadMessageAssistantDeltaCommand = Schema.Struct({ +const ThreadMessageAssistantDeltaCommand = Schema.Struct({ type: Schema.Literal("thread.message.assistant.delta"), commandId: CommandId, threadId: ThreadId, @@ -424,7 +473,7 @@ export const ThreadMessageAssistantDeltaCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadMessageAssistantCompleteCommand = Schema.Struct({ +const ThreadMessageAssistantCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.message.assistant.complete"), commandId: CommandId, threadId: ThreadId, @@ -433,7 +482,15 @@ export const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadTurnDiffCompleteCommand = Schema.Struct({ +const ThreadProposedPlanUpsertCommand = Schema.Struct({ + type: Schema.Literal("thread.proposed-plan.upsert"), + commandId: CommandId, + threadId: ThreadId, + proposedPlan: OrchestrationProposedPlan, + createdAt: IsoDateTime, +}); + +const ThreadTurnDiffCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.turn.diff.complete"), commandId: CommandId, threadId: ThreadId, @@ -447,7 +504,7 @@ export const ThreadTurnDiffCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadActivityAppendCommand = Schema.Struct({ +const ThreadActivityAppendCommand = Schema.Struct({ type: Schema.Literal("thread.activity.append"), commandId: CommandId, threadId: ThreadId, @@ -455,7 +512,7 @@ export const ThreadActivityAppendCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ThreadRevertCompleteCommand = Schema.Struct({ +const ThreadRevertCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.revert.complete"), commandId: CommandId, threadId: ThreadId, @@ -463,10 +520,11 @@ export const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const InternalOrchestrationCommand = Schema.Union([ +const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, @@ -486,14 +544,18 @@ export const OrchestrationEventType = Schema.Literals([ "thread.created", "thread.deleted", "thread.meta-updated", + "thread.runtime-mode-set", + "thread.interaction-mode-set", "thread.message-sent", "thread.turn-start-requested", "thread.turn-interrupt-requested", "thread.approval-response-requested", + "thread.user-input-response-requested", "thread.checkpoint-revert-requested", "thread.reverted", "thread.session-stop-requested", "thread.session-set", + "thread.proposed-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", ]); @@ -532,6 +594,10 @@ export const ThreadCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, @@ -552,6 +618,20 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadRuntimeModeSetPayload = Schema.Struct({ + threadId: ThreadId, + runtimeMode: RuntimeMode, + updatedAt: IsoDateTime, +}); + +export const ThreadInteractionModeSetPayload = Schema.Struct({ + threadId: ThreadId, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + updatedAt: IsoDateTime, +}); + export const ThreadMessageSentPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, @@ -567,11 +647,14 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, + provider: Schema.optional(ProviderKind), model: Schema.optional(TrimmedNonEmptyString), - effort: Schema.optional(TrimmedNonEmptyString), + modelOptions: Schema.optional(ProviderModelOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), - sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), createdAt: IsoDateTime, }); @@ -588,6 +671,13 @@ export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadUserInputResponseRequestedPayload = Schema.Struct({ + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + createdAt: IsoDateTime, +}); + export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ threadId: ThreadId, turnCount: NonNegativeInt, @@ -609,6 +699,11 @@ export const ThreadSessionSetPayload = Schema.Struct({ session: OrchestrationSession, }); +export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ + threadId: ThreadId, + proposedPlan: OrchestrationProposedPlan, +}); + export const ThreadTurnDiffCompletedPayload = Schema.Struct({ threadId: ThreadId, turnId: TurnId, @@ -626,9 +721,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ }); export const OrchestrationEventMetadata = Schema.Struct({ - providerSessionId: Schema.optional(ProviderSessionId), - providerThreadId: Schema.optional(ProviderThreadId), - providerTurnId: Schema.optional(ProviderTurnId), + providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), @@ -693,6 +786,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.runtime-mode-set"), + payload: ThreadRuntimeModeSetPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.interaction-mode-set"), + payload: ThreadInteractionModeSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.message-sent"), @@ -713,6 +816,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.approval-response-requested"), payload: ThreadApprovalResponseRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.user-input-response-requested"), + payload: ThreadUserInputResponseRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.checkpoint-revert-requested"), @@ -733,6 +841,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.session-set"), payload: ThreadSessionSetPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.proposed-plan-upserted"), + payload: ThreadProposedPlanUpsertedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-diff-completed"), @@ -777,6 +890,16 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.runtime-mode-set"), + payload: ThreadRuntimeModeSetPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.interaction-mode-set"), + payload: ThreadInteractionModeSetPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.message-sent"), @@ -797,6 +920,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.approval-response-requested"), payload: ThreadApprovalResponseRequestedPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.user-input-response-requested"), + payload: ThreadUserInputResponseRequestedPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.checkpoint-revert-requested"), @@ -817,6 +945,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.session-set"), payload: ThreadSessionSetPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.proposed-plan-upserted"), + payload: ThreadProposedPlanUpsertedPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.turn-diff-completed"), @@ -863,7 +996,7 @@ export const ProviderSessionRuntimeStatus = Schema.Literals([ ]); export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type; -export const ProjectionThreadTurnStatus = Schema.Literals([ +const ProjectionThreadTurnStatus = Schema.Literals([ "running", "completed", "interrupted", @@ -871,7 +1004,7 @@ export const ProjectionThreadTurnStatus = Schema.Literals([ ]); export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type; -export const ProjectionCheckpointRow = Schema.Struct({ +const ProjectionCheckpointRow = Schema.Struct({ threadId: ThreadId, turnId: TurnId, checkpointTurnCount: NonNegativeInt, @@ -896,7 +1029,7 @@ export type DispatchResult = typeof DispatchResult.Type; export const OrchestrationGetSnapshotInput = Schema.Struct({}); export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; -export const OrchestrationGetSnapshotResult = OrchestrationReadModel; +const OrchestrationGetSnapshotResult = OrchestrationReadModel; export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( @@ -922,7 +1055,7 @@ export const OrchestrationReplayEventsInput = Schema.Struct({ }); export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type; -export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); +const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; export const OrchestrationRpcSchemas = { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f3468d55d0..4d1450bacf 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,7 +1,8 @@ import { Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -10,7 +11,7 @@ export const ProjectSearchEntriesInput = Schema.Struct({ }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; -export const ProjectEntryKind = Schema.Literals(["file", "directory"]); +const ProjectEntryKind = Schema.Literals(["file", "directory"]); export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, @@ -24,3 +25,17 @@ export const ProjectSearchEntriesResult = Schema.Struct({ truncated: Schema.Boolean, }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; + +export const ProjectWriteFileInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH), + ), + contents: Schema.String, +}); +export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type; + +export const ProjectWriteFileResult = Schema.Struct({ + relativePath: TrimmedNonEmptyString, +}); +export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts new file mode 100644 index 0000000000..97e168a33a --- /dev/null +++ b/packages/contracts/src/provider.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider"; + +const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); +const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); + +describe("ProviderSessionStartInput", () => { + it("accepts codex-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "codex", + cwd: "/tmp/workspace", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + runtimeMode: "full-access", + providerOptions: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/tmp/.codex", + }, + }, + }); + expect(parsed.runtimeMode).toBe("full-access"); + expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high"); + expect(parsed.modelOptions?.codex?.fastMode).toBe(true); + expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); + expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); + }); + + it("rejects payloads without runtime mode", () => { + expect(() => + decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "codex", + }), + ).toThrow(); + }); + + it("accepts claude runtime knobs", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "claudeCode", + cwd: "/tmp/workspace", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, + }, + }, + runtimeMode: "full-access", + }); + expect(parsed.provider).toBe("claudeCode"); + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); + expect(parsed.providerOptions?.claudeCode?.permissionMode).toBe("plan"); + expect(parsed.providerOptions?.claudeCode?.maxThinkingTokens).toBe(12_000); + expect(parsed.runtimeMode).toBe("full-access"); + }); + + it("accepts cursor provider payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "cursor", + cwd: "/tmp/workspace", + model: "composer-1.5", + modelOptions: { + cursor: { + thinking: true, + }, + }, + providerOptions: { + cursor: { + binaryPath: "/usr/local/bin/agent", + }, + }, + runtimeMode: "approval-required", + }); + expect(parsed.provider).toBe("cursor"); + expect(parsed.model).toBe("composer-1.5"); + expect(parsed.modelOptions?.cursor?.thinking).toBe(true); + expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent"); + expect(parsed.runtimeMode).toBe("approval-required"); + }); +}); + +describe("ProviderSendTurnInput", () => { + it("accepts provider-scoped model options", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "xhigh", + fastMode: true, + }, + }, + }); + + expect(parsed.model).toBe("gpt-5.3-codex"); + expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); + expect(parsed.modelOptions?.codex?.fastMode).toBe(true); + }); +}); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 639d5dcb3f..21687a7f4f 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,69 +1,86 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderModelOptions } from "./model"; import { ApprovalRequestId, EventId, IsoDateTime, NonNegativeInt, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "./baseSchemas"; import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderApprovalDecision, - ProviderApprovalPolicy, + ProviderInteractionMode, ProviderKind, ProviderRequestKind, - ProviderSandboxMode, - TurnCountRange, + ProviderUserInputAnswers, + RuntimeMode, } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; - -export const ProviderSessionStatus = Schema.Literals([ +const ProviderSessionStatus = Schema.Literals([ "connecting", "ready", "running", "error", "closed", ]); -export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; export const ProviderSession = Schema.Struct({ - sessionId: ProviderSessionId, provider: ProviderKind, status: ProviderSessionStatus, + runtimeMode: RuntimeMode, cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), - threadId: Schema.optional(ProviderThreadId), + threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), - activeTurnId: Schema.optional(ProviderTurnId), + activeTurnId: Schema.optional(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, lastError: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderSession = typeof ProviderSession.Type; +const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + homePath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + permissionMode: Schema.optional(TrimmedNonEmptyStringSchema), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); + +const CursorProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), +}); + export const ProviderSessionStartInput = Schema.Struct({ + threadId: ThreadId, provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), - resumeThreadId: Schema.optional(ProviderThreadId), + modelOptions: Schema.optional(ProviderModelOptions), resumeCursor: Schema.optional(Schema.Unknown), - codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), - approvalPolicy: Schema.optional(ProviderApprovalPolicy), - sandboxMode: Schema.optional(ProviderSandboxMode), + providerOptions: Schema.optional(ProviderStartOptions), + runtimeMode: RuntimeMode, }); export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, input: Schema.optional( TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), @@ -71,97 +88,54 @@ export const ProviderSendTurnInput = Schema.Struct({ Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), ), model: Schema.optional(TrimmedNonEmptyStringSchema), - effort: Schema.optional(TrimmedNonEmptyStringSchema), + modelOptions: Schema.optional(ProviderModelOptions), + interactionMode: Schema.optional(ProviderInteractionMode), }); export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; export const ProviderTurnStartResult = Schema.Struct({ - threadId: ProviderThreadId, - turnId: ProviderTurnId, + threadId: ThreadId, + turnId: TurnId, resumeCursor: Schema.optional(Schema.Unknown), }); export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; export const ProviderInterruptTurnInput = Schema.Struct({ - sessionId: ProviderSessionId, - turnId: Schema.optional(ProviderTurnId), + threadId: ThreadId, + turnId: Schema.optional(TurnId), }); export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; export const ProviderStopSessionInput = Schema.Struct({ - sessionId: ProviderSessionId, -}); -export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; - -export const ProviderListCheckpointsInput = Schema.Struct({ - sessionId: ProviderSessionId, -}); -export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; - -export const ProviderCheckpoint = Schema.Struct({ - id: TrimmedNonEmptyStringSchema, - turnCount: NonNegativeInt, - messageCount: NonNegativeInt, - label: TrimmedNonEmptyStringSchema, - preview: Schema.optional(TrimmedNonEmptyStringSchema), - isCurrent: Schema.Boolean, -}); -export type ProviderCheckpoint = typeof ProviderCheckpoint.Type; - -export const ProviderListCheckpointsResult = Schema.Struct({ threadId: ThreadId, - checkpoints: Schema.Array(ProviderCheckpoint), }); -export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; - -export const ProviderRevertToCheckpointInput = Schema.Struct({ - sessionId: ProviderSessionId, - turnCount: NonNegativeInt, -}); -export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; +export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; -export const ProviderRevertToCheckpointResult = Schema.Struct({ +export const ProviderRespondToRequestInput = Schema.Struct({ threadId: ThreadId, - turnCount: NonNegativeInt, - messageCount: NonNegativeInt, - rolledBackTurns: NonNegativeInt, - checkpoints: Schema.Array(ProviderCheckpoint), -}); -export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; - -export const ProviderGetCheckpointDiffInput = Schema.Struct({ - sessionId: ProviderSessionId, - ...TurnCountRange.fields, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, }); -export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; +export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; -export const ProviderGetCheckpointDiffResult = Schema.Struct({ +export const ProviderRespondToUserInputInput = Schema.Struct({ threadId: ThreadId, - ...TurnCountRange.fields, - diff: Schema.String, -}); -export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; - -export const ProviderRespondToRequestInput = Schema.Struct({ - sessionId: ProviderSessionId, requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, + answers: ProviderUserInputAnswers, }); -export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; +export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; -export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); -export type ProviderEventKind = typeof ProviderEventKind.Type; +const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); export const ProviderEvent = Schema.Struct({ id: EventId, kind: ProviderEventKind, provider: ProviderKind, - sessionId: ProviderSessionId, + threadId: ThreadId, createdAt: IsoDateTime, method: TrimmedNonEmptyStringSchema, message: Schema.optional(TrimmedNonEmptyStringSchema), - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), + turnId: Schema.optional(TurnId), itemId: Schema.optional(ProviderItemId), requestId: Schema.optional(ApprovalRequestId), requestKind: Schema.optional(ProviderRequestKind), @@ -169,6 +143,3 @@ export const ProviderEvent = Schema.Struct({ payload: Schema.optional(Schema.Unknown), }); export type ProviderEvent = typeof ProviderEvent.Type; - -export type ProviderSendTurnAttachment = typeof ChatAttachment.Type; -export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts new file mode 100644 index 0000000000..7f578c276e --- /dev/null +++ b/packages/contracts/src/providerRuntime.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ProviderRuntimeEvent } from "./providerRuntime"; + +const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); + +describe("ProviderRuntimeEvent", () => { + it("decodes turn.plan.updated for plan rendering", () => { + const parsed = decodeRuntimeEvent({ + type: "turn.plan.updated", + eventId: "event-1", + provider: "claudeCode", + sessionId: "runtime-session-1", + createdAt: "2026-02-28T00:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + payload: { + explanation: "Implement schema updates", + plan: [ + { step: "Define event union", status: "completed" }, + { step: "Wire adapter mapping", status: "inProgress" }, + ], + }, + }); + + expect(parsed.type).toBe("turn.plan.updated"); + if (parsed.type !== "turn.plan.updated") { + throw new Error("expected turn.plan.updated"); + } + expect(parsed.payload.plan).toHaveLength(2); + expect(parsed.payload.plan[1]?.status).toBe("inProgress"); + }); + + it("decodes proposed-plan completion events", () => { + const parsed = decodeRuntimeEvent({ + type: "turn.proposed.completed", + eventId: "event-proposed-plan-1", + provider: "codex", + createdAt: "2026-02-28T00:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + payload: { + planMarkdown: "# Ship it", + }, + }); + + expect(parsed.type).toBe("turn.proposed.completed"); + if (parsed.type !== "turn.proposed.completed") { + throw new Error("expected turn.proposed.completed"); + } + expect(parsed.payload.planMarkdown).toBe("# Ship it"); + }); + + it("decodes user-input.requested with structured questions", () => { + const parsed = decodeRuntimeEvent({ + type: "user-input.requested", + eventId: "event-2", + provider: "claudeCode", + sessionId: "runtime-session-2", + createdAt: "2026-02-28T00:00:01.000Z", + threadId: "thread-2", + requestId: "request-1", + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow edits in workspace only", + }, + { + label: "danger-full-access", + description: "Allow unrestricted access", + }, + ], + }, + ], + }, + }); + + expect(parsed.type).toBe("user-input.requested"); + if (parsed.type !== "user-input.requested") { + throw new Error("expected user-input.requested"); + } + expect(parsed.payload.questions[0]?.id).toBe("sandbox_mode"); + expect(parsed.payload.questions[0]?.options).toHaveLength(2); + }); + + it("decodes user-input.resolved with answer map", () => { + const parsed = decodeRuntimeEvent({ + type: "user-input.resolved", + eventId: "event-3", + provider: "claudeCode", + sessionId: "runtime-session-2", + createdAt: "2026-02-28T00:00:02.000Z", + threadId: "thread-2", + requestId: "request-1", + payload: { + answers: { + sandbox_mode: "workspace-write", + }, + }, + }); + + expect(parsed.type).toBe("user-input.resolved"); + if (parsed.type !== "user-input.resolved") { + throw new Error("expected user-input.resolved"); + } + expect(parsed.payload.answers.sandbox_mode).toBe("workspace-write"); + }); + + it("rejects legacy message.delta type", () => { + expect(() => + decodeRuntimeEvent({ + type: "message.delta", + eventId: "event-4", + provider: "codex", + sessionId: "runtime-session-3", + createdAt: "2026-02-28T00:00:03.000Z", + payload: { delta: "legacy" }, + }), + ).toThrow(); + }); + + it("rejects empty branded canonical ids", () => { + expect(() => + decodeRuntimeEvent({ + type: "runtime.error", + eventId: "event-5", + provider: "codex", + sessionId: "runtime-session-3", + createdAt: "2026-02-28T00:00:03.000Z", + threadId: " ", + payload: { message: "boom" }, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 1544fda1cc..66bd61fba9 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,213 +1,986 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; - import { - ApprovalRequestId, EventId, - NonNegativeInt, - ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, IsoDateTime, + ProviderItemId, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TrimmedNonEmptyString, + TurnId, } from "./baseSchemas"; -import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration"; +import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); -export const ProviderRuntimeToolKind = Schema.Union([ProviderRequestKind, Schema.Literal("other")]); -export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; +const RuntimeEventRawSource = Schema.Literals([ + "codex.app-server.notification", + "codex.app-server.request", + "codex.eventmsg", + "claude.sdk.message", + "claude.sdk.permission", + "codex.sdk.thread-event", + "cursor.acp.notification", + "cursor.acp.request", + "cursor.acp.response", +]); +export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; + +export const RuntimeEventRaw = Schema.Struct({ + source: RuntimeEventRawSource, + method: Schema.optional(TrimmedNonEmptyStringSchema), + messageType: Schema.optional(TrimmedNonEmptyStringSchema), + payload: Schema.Unknown, +}); +export type RuntimeEventRaw = typeof RuntimeEventRaw.Type; + +const ProviderRequestId = TrimmedNonEmptyStringSchema; +export type ProviderRequestId = typeof ProviderRequestId.Type; -export const ProviderRuntimeTurnStatus = Schema.Literals([ +const ProviderRefs = Schema.Struct({ + providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema), + providerItemId: Schema.optional(ProviderItemId), + providerRequestId: Schema.optional(ProviderRequestId), +}); +export type ProviderRefs = typeof ProviderRefs.Type; + +const RuntimeSessionState = Schema.Literals([ + "starting", + "ready", + "running", + "waiting", + "stopped", + "error", +]); +export type RuntimeSessionState = typeof RuntimeSessionState.Type; + +const RuntimeThreadState = Schema.Literals([ + "active", + "idle", + "archived", + "closed", + "compacted", + "error", +]); +export type RuntimeThreadState = typeof RuntimeThreadState.Type; + +const RuntimeTurnState = Schema.Literals([ "completed", "failed", - "cancelled", "interrupted", + "cancelled", ]); -export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type; +export type RuntimeTurnState = typeof RuntimeTurnState.Type; -export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ - type: Schema.Literal("session.started"), +const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]); +export type RuntimePlanStepStatus = typeof RuntimePlanStepStatus.Type; + +const RuntimeItemStatus = Schema.Literals(["inProgress", "completed", "failed", "declined"]); +export type RuntimeItemStatus = typeof RuntimeItemStatus.Type; + +const RuntimeContentStreamKind = Schema.Literals([ + "assistant_text", + "reasoning_text", + "reasoning_summary_text", + "plan_text", + "command_output", + "file_change_output", + "unknown", +]); +export type RuntimeContentStreamKind = typeof RuntimeContentStreamKind.Type; + +const RuntimeSessionExitKind = Schema.Literals(["graceful", "error"]); +export type RuntimeSessionExitKind = typeof RuntimeSessionExitKind.Type; + +const RuntimeErrorClass = Schema.Literals([ + "provider_error", + "transport_error", + "permission_error", + "validation_error", + "unknown", +]); +export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; + +export const CanonicalItemType = Schema.Literals([ + "user_message", + "assistant_message", + "reasoning", + "plan", + "command_execution", + "file_change", + "mcp_tool_call", + "dynamic_tool_call", + "collab_agent_tool_call", + "web_search", + "image_view", + "review_entered", + "review_exited", + "context_compaction", + "error", + "unknown", +]); +export type CanonicalItemType = typeof CanonicalItemType.Type; + +export const CanonicalRequestType = Schema.Literals([ + "command_execution_approval", + "file_read_approval", + "file_change_approval", + "apply_patch_approval", + "exec_command_approval", + "tool_user_input", + "dynamic_tool_call", + "auth_tokens_refresh", + "unknown", +]); +export type CanonicalRequestType = typeof CanonicalRequestType.Type; + +const ProviderRuntimeEventType = Schema.Literals([ + "session.started", + "session.configured", + "session.state.changed", + "session.exited", + "thread.started", + "thread.state.changed", + "thread.metadata.updated", + "thread.token-usage.updated", + "thread.realtime.started", + "thread.realtime.item-added", + "thread.realtime.audio.delta", + "thread.realtime.error", + "thread.realtime.closed", + "turn.started", + "turn.completed", + "turn.aborted", + "turn.plan.updated", + "turn.proposed.delta", + "turn.proposed.completed", + "turn.diff.updated", + "item.started", + "item.updated", + "item.completed", + "content.delta", + "request.opened", + "request.resolved", + "user-input.requested", + "user-input.resolved", + "task.started", + "task.progress", + "task.completed", + "hook.started", + "hook.progress", + "hook.completed", + "tool.progress", + "tool.summary", + "auth.status", + "account.updated", + "account.rate-limits.updated", + "mcp.status.updated", + "mcp.oauth.completed", + "model.rerouted", + "config.warning", + "deprecation.notice", + "files.persisted", + "runtime.warning", + "runtime.error", +]); +export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type; + +const SessionStartedType = Schema.Literal("session.started"); +const SessionConfiguredType = Schema.Literal("session.configured"); +const SessionStateChangedType = Schema.Literal("session.state.changed"); +const SessionExitedType = Schema.Literal("session.exited"); +const ThreadStartedType = Schema.Literal("thread.started"); +const ThreadStateChangedType = Schema.Literal("thread.state.changed"); +const ThreadMetadataUpdatedType = Schema.Literal("thread.metadata.updated"); +const ThreadTokenUsageUpdatedType = Schema.Literal("thread.token-usage.updated"); +const ThreadRealtimeStartedType = Schema.Literal("thread.realtime.started"); +const ThreadRealtimeItemAddedType = Schema.Literal("thread.realtime.item-added"); +const ThreadRealtimeAudioDeltaType = Schema.Literal("thread.realtime.audio.delta"); +const ThreadRealtimeErrorType = Schema.Literal("thread.realtime.error"); +const ThreadRealtimeClosedType = Schema.Literal("thread.realtime.closed"); +const TurnStartedType = Schema.Literal("turn.started"); +const TurnCompletedType = Schema.Literal("turn.completed"); +const TurnAbortedType = Schema.Literal("turn.aborted"); +const TurnPlanUpdatedType = Schema.Literal("turn.plan.updated"); +const TurnProposedDeltaType = Schema.Literal("turn.proposed.delta"); +const TurnProposedCompletedType = Schema.Literal("turn.proposed.completed"); +const TurnDiffUpdatedType = Schema.Literal("turn.diff.updated"); +const ItemStartedType = Schema.Literal("item.started"); +const ItemUpdatedType = Schema.Literal("item.updated"); +const ItemCompletedType = Schema.Literal("item.completed"); +const ContentDeltaType = Schema.Literal("content.delta"); +const RequestOpenedType = Schema.Literal("request.opened"); +const RequestResolvedType = Schema.Literal("request.resolved"); +const UserInputRequestedType = Schema.Literal("user-input.requested"); +const UserInputResolvedType = Schema.Literal("user-input.resolved"); +const TaskStartedType = Schema.Literal("task.started"); +const TaskProgressType = Schema.Literal("task.progress"); +const TaskCompletedType = Schema.Literal("task.completed"); +const HookStartedType = Schema.Literal("hook.started"); +const HookProgressType = Schema.Literal("hook.progress"); +const HookCompletedType = Schema.Literal("hook.completed"); +const ToolProgressType = Schema.Literal("tool.progress"); +const ToolSummaryType = Schema.Literal("tool.summary"); +const AuthStatusType = Schema.Literal("auth.status"); +const AccountUpdatedType = Schema.Literal("account.updated"); +const AccountRateLimitsUpdatedType = Schema.Literal("account.rate-limits.updated"); +const McpStatusUpdatedType = Schema.Literal("mcp.status.updated"); +const McpOauthCompletedType = Schema.Literal("mcp.oauth.completed"); +const ModelReroutedType = Schema.Literal("model.rerouted"); +const ConfigWarningType = Schema.Literal("config.warning"); +const DeprecationNoticeType = Schema.Literal("deprecation.notice"); +const FilesPersistedType = Schema.Literal("files.persisted"); +const RuntimeWarningType = Schema.Literal("runtime.warning"); +const RuntimeErrorType = Schema.Literal("runtime.error"); + +const ProviderRuntimeEventBase = Schema.Struct({ eventId: EventId, provider: ProviderKind, - sessionId: ProviderSessionId, + threadId: ThreadId, createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - message: Schema.optional(TrimmedNonEmptyStringSchema), + turnId: Schema.optional(TurnId), + itemId: Schema.optional(RuntimeItemId), + requestId: Schema.optional(RuntimeRequestId), + providerRefs: Schema.optional(ProviderRefs), + raw: Schema.optional(RuntimeEventRaw), }); -export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; +export type ProviderRuntimeEventBase = typeof ProviderRuntimeEventBase.Type; -export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ - type: Schema.Literal("session.exited"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), +const SessionStartedPayload = Schema.Struct({ message: Schema.optional(TrimmedNonEmptyStringSchema), + resume: Schema.optional(Schema.Unknown), }); -export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; +export type SessionStartedPayload = typeof SessionStartedPayload.Type; -export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ - type: Schema.Literal("thread.started"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: ProviderThreadId, +const SessionConfiguredPayload = Schema.Struct({ + config: UnknownRecordSchema, }); -export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; +export type SessionConfiguredPayload = typeof SessionConfiguredPayload.Type; -export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ - type: Schema.Literal("turn.started"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: ProviderTurnId, +const SessionStateChangedPayload = Schema.Struct({ + state: RuntimeSessionState, + reason: Schema.optional(TrimmedNonEmptyStringSchema), + detail: Schema.optional(Schema.Unknown), }); -export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; +export type SessionStateChangedPayload = typeof SessionStateChangedPayload.Type; -export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ - type: Schema.Literal("turn.completed"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - status: Schema.optional(ProviderRuntimeTurnStatus), +const SessionExitedPayload = Schema.Struct({ + reason: Schema.optional(TrimmedNonEmptyStringSchema), + recoverable: Schema.optional(Schema.Boolean), + exitKind: Schema.optional(RuntimeSessionExitKind), +}); +export type SessionExitedPayload = typeof SessionExitedPayload.Type; + +const ThreadStartedPayload = Schema.Struct({ + providerThreadId: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; + +const ThreadStateChangedPayload = Schema.Struct({ + state: RuntimeThreadState, + detail: Schema.optional(Schema.Unknown), +}); +export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; + +const ThreadMetadataUpdatedPayload = Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyStringSchema), + metadata: Schema.optional(UnknownRecordSchema), +}); +export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; + +const ThreadTokenUsageUpdatedPayload = Schema.Struct({ + usage: Schema.Unknown, +}); +export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; + +const ThreadRealtimeStartedPayload = Schema.Struct({ + realtimeSessionId: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ThreadRealtimeStartedPayload = typeof ThreadRealtimeStartedPayload.Type; + +const ThreadRealtimeItemAddedPayload = Schema.Struct({ + item: Schema.Unknown, +}); +export type ThreadRealtimeItemAddedPayload = typeof ThreadRealtimeItemAddedPayload.Type; + +const ThreadRealtimeAudioDeltaPayload = Schema.Struct({ + audio: Schema.Unknown, +}); +export type ThreadRealtimeAudioDeltaPayload = typeof ThreadRealtimeAudioDeltaPayload.Type; + +const ThreadRealtimeErrorPayload = Schema.Struct({ + message: TrimmedNonEmptyStringSchema, +}); +export type ThreadRealtimeErrorPayload = typeof ThreadRealtimeErrorPayload.Type; + +const ThreadRealtimeClosedPayload = Schema.Struct({ + reason: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ThreadRealtimeClosedPayload = typeof ThreadRealtimeClosedPayload.Type; + +const TurnStartedPayload = Schema.Struct({ + model: Schema.optional(TrimmedNonEmptyStringSchema), + effort: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TurnStartedPayload = typeof TurnStartedPayload.Type; + +const TurnCompletedPayload = Schema.Struct({ + state: RuntimeTurnState, + stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), + usage: Schema.optional(Schema.Unknown), + modelUsage: Schema.optional(UnknownRecordSchema), + totalCostUsd: Schema.optional(Schema.Number), errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), }); -export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; +export type TurnCompletedPayload = typeof TurnCompletedPayload.Type; -export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({ - type: Schema.Literal("message.delta"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), +const TurnAbortedPayload = Schema.Struct({ + reason: TrimmedNonEmptyStringSchema, +}); +export type TurnAbortedPayload = typeof TurnAbortedPayload.Type; + +const RuntimePlanStep = Schema.Struct({ + step: TrimmedNonEmptyStringSchema, + status: RuntimePlanStepStatus, +}); +export type RuntimePlanStep = typeof RuntimePlanStep.Type; + +const TurnPlanUpdatedPayload = Schema.Struct({ + explanation: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), + plan: Schema.Array(RuntimePlanStep), +}); +export type TurnPlanUpdatedPayload = typeof TurnPlanUpdatedPayload.Type; + +const TurnProposedDeltaPayload = Schema.Struct({ delta: Schema.String, }); -export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type; +export type TurnProposedDeltaPayload = typeof TurnProposedDeltaPayload.Type; -export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({ - type: Schema.Literal("message.completed"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - itemId: ProviderItemId, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), +const TurnProposedCompletedPayload = Schema.Struct({ + planMarkdown: TrimmedNonEmptyStringSchema, }); -export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type; +export type TurnProposedCompletedPayload = typeof TurnProposedCompletedPayload.Type; -export const ProviderRuntimeToolStartedEvent = Schema.Struct({ - type: Schema.Literal("tool.started"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), - toolKind: ProviderRuntimeToolKind, - title: TrimmedNonEmptyStringSchema, - detail: Schema.optional(TrimmedNonEmptyStringSchema), +const TurnDiffUpdatedPayload = Schema.Struct({ + unifiedDiff: Schema.String, }); -export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; +export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type; -export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ - type: Schema.Literal("tool.completed"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), - toolKind: ProviderRuntimeToolKind, - title: TrimmedNonEmptyStringSchema, +export const ItemLifecyclePayload = Schema.Struct({ + itemType: CanonicalItemType, + status: Schema.optional(RuntimeItemStatus), + title: Schema.optional(TrimmedNonEmptyStringSchema), detail: Schema.optional(TrimmedNonEmptyStringSchema), + data: Schema.optional(Schema.Unknown), }); -export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; +export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type; -export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ - type: Schema.Literal("approval.requested"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), - requestId: ApprovalRequestId, - requestKind: ProviderRequestKind, +const ContentDeltaPayload = Schema.Struct({ + streamKind: RuntimeContentStreamKind, + delta: Schema.String, + contentIndex: Schema.optional(Schema.Int), + summaryIndex: Schema.optional(Schema.Int), +}); +export type ContentDeltaPayload = typeof ContentDeltaPayload.Type; + +const RequestOpenedPayload = Schema.Struct({ + requestType: CanonicalRequestType, detail: Schema.optional(TrimmedNonEmptyStringSchema), + args: Schema.optional(Schema.Unknown), }); -export type ProviderRuntimeApprovalRequestedEvent = - typeof ProviderRuntimeApprovalRequestedEvent.Type; +export type RequestOpenedPayload = typeof RequestOpenedPayload.Type; -export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({ - type: Schema.Literal("approval.resolved"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), - requestId: ApprovalRequestId, - requestKind: Schema.optional(ProviderRequestKind), - decision: Schema.optional(ProviderApprovalDecision), +const RequestResolvedPayload = Schema.Struct({ + requestType: CanonicalRequestType, + decision: Schema.optional(TrimmedNonEmptyStringSchema), + resolution: Schema.optional(Schema.Unknown), }); -export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type; +export type RequestResolvedPayload = typeof RequestResolvedPayload.Type; -export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({ - type: Schema.Literal("checkpoint.captured"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: ProviderThreadId, - turnId: Schema.optional(ProviderTurnId), - turnCount: NonNegativeInt, - status: Schema.optional(ProviderRuntimeTurnStatus), +const UserInputQuestionOption = Schema.Struct({ + label: TrimmedNonEmptyStringSchema, + description: TrimmedNonEmptyStringSchema, }); -export type ProviderRuntimeCheckpointCapturedEvent = - typeof ProviderRuntimeCheckpointCapturedEvent.Type; +export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; -export const ProviderRuntimeErrorEvent = Schema.Struct({ - type: Schema.Literal("runtime.error"), - eventId: EventId, - provider: ProviderKind, - sessionId: ProviderSessionId, - createdAt: IsoDateTime, - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), - itemId: Schema.optional(ProviderItemId), +export const UserInputQuestion = Schema.Struct({ + id: TrimmedNonEmptyStringSchema, + header: TrimmedNonEmptyStringSchema, + question: TrimmedNonEmptyStringSchema, + options: Schema.Array(UserInputQuestionOption), +}); +export type UserInputQuestion = typeof UserInputQuestion.Type; + +const UserInputRequestedPayload = Schema.Struct({ + questions: Schema.Array(UserInputQuestion), +}); +export type UserInputRequestedPayload = typeof UserInputRequestedPayload.Type; + +const UserInputResolvedPayload = Schema.Struct({ + answers: UnknownRecordSchema, +}); +export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; + +const TaskStartedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + description: Schema.optional(TrimmedNonEmptyStringSchema), + taskType: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TaskStartedPayload = typeof TaskStartedPayload.Type; + +const TaskProgressPayload = Schema.Struct({ + taskId: RuntimeTaskId, + description: TrimmedNonEmptyStringSchema, + usage: Schema.optional(Schema.Unknown), + lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TaskProgressPayload = typeof TaskProgressPayload.Type; + +const TaskCompletedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + status: Schema.Literals(["completed", "failed", "stopped"]), + summary: Schema.optional(TrimmedNonEmptyStringSchema), + usage: Schema.optional(Schema.Unknown), +}); +export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; + +const HookStartedPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + hookName: TrimmedNonEmptyStringSchema, + hookEvent: TrimmedNonEmptyStringSchema, +}); +export type HookStartedPayload = typeof HookStartedPayload.Type; + +const HookProgressPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + output: Schema.optional(Schema.String), + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), +}); +export type HookProgressPayload = typeof HookProgressPayload.Type; + +const HookCompletedPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + outcome: Schema.Literals(["success", "error", "cancelled"]), + output: Schema.optional(Schema.String), + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Int), +}); +export type HookCompletedPayload = typeof HookCompletedPayload.Type; + +const ToolProgressPayload = Schema.Struct({ + toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + toolName: Schema.optional(TrimmedNonEmptyStringSchema), + summary: Schema.optional(TrimmedNonEmptyStringSchema), + elapsedSeconds: Schema.optional(Schema.Number), +}); +export type ToolProgressPayload = typeof ToolProgressPayload.Type; + +const ToolSummaryPayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + precedingToolUseIds: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), +}); +export type ToolSummaryPayload = typeof ToolSummaryPayload.Type; + +const AuthStatusPayload = Schema.Struct({ + isAuthenticating: Schema.optional(Schema.Boolean), + output: Schema.optional(Schema.Array(Schema.String)), + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type AuthStatusPayload = typeof AuthStatusPayload.Type; + +const AccountUpdatedPayload = Schema.Struct({ + account: Schema.Unknown, +}); +export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type; + +const AccountRateLimitsUpdatedPayload = Schema.Struct({ + rateLimits: Schema.Unknown, +}); +export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type; + +const McpStatusUpdatedPayload = Schema.Struct({ + status: Schema.Unknown, +}); +export type McpStatusUpdatedPayload = typeof McpStatusUpdatedPayload.Type; + +const McpOauthCompletedPayload = Schema.Struct({ + success: Schema.Boolean, + name: Schema.optional(TrimmedNonEmptyStringSchema), + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type McpOauthCompletedPayload = typeof McpOauthCompletedPayload.Type; + +const ModelReroutedPayload = Schema.Struct({ + fromModel: TrimmedNonEmptyStringSchema, + toModel: TrimmedNonEmptyStringSchema, + reason: TrimmedNonEmptyStringSchema, +}); +export type ModelReroutedPayload = typeof ModelReroutedPayload.Type; + +const ConfigWarningPayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + details: Schema.optional(TrimmedNonEmptyStringSchema), + path: Schema.optional(TrimmedNonEmptyStringSchema), + range: Schema.optional(Schema.Unknown), +}); +export type ConfigWarningPayload = typeof ConfigWarningPayload.Type; + +const DeprecationNoticePayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + details: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type DeprecationNoticePayload = typeof DeprecationNoticePayload.Type; + +const FilesPersistedPayload = Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + filename: TrimmedNonEmptyStringSchema, + fileId: TrimmedNonEmptyStringSchema, + }), + ), + failed: Schema.optional( + Schema.Array( + Schema.Struct({ + filename: TrimmedNonEmptyStringSchema, + error: TrimmedNonEmptyStringSchema, + }), + ), + ), +}); +export type FilesPersistedPayload = typeof FilesPersistedPayload.Type; + +const RuntimeWarningPayload = Schema.Struct({ + message: TrimmedNonEmptyStringSchema, + detail: Schema.optional(Schema.Unknown), +}); +export type RuntimeWarningPayload = typeof RuntimeWarningPayload.Type; + +const RuntimeErrorPayload = Schema.Struct({ message: TrimmedNonEmptyStringSchema, + class: Schema.optional(RuntimeErrorClass), + detail: Schema.optional(Schema.Unknown), +}); +export type RuntimeErrorPayload = typeof RuntimeErrorPayload.Type; + +const ProviderRuntimeSessionStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionStartedType, + payload: SessionStartedPayload, +}); +export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; + +const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionConfiguredType, + payload: SessionConfiguredPayload, +}); +export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type; + +const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionStateChangedType, + payload: SessionStateChangedPayload, +}); +export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type; + +const ProviderRuntimeSessionExitedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionExitedType, + payload: SessionExitedPayload, +}); +export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; + +const ProviderRuntimeThreadStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadStartedType, + payload: ThreadStartedPayload, +}); +export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; + +const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadStateChangedType, + payload: ThreadStateChangedPayload, +}); +export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type; + +const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadMetadataUpdatedType, + payload: ThreadMetadataUpdatedPayload, +}); +export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; + +const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadTokenUsageUpdatedType, + payload: ThreadTokenUsageUpdatedPayload, +}); +export type ProviderRuntimeThreadTokenUsageUpdatedEvent = + typeof ProviderRuntimeThreadTokenUsageUpdatedEvent.Type; + +const ProviderRuntimeThreadRealtimeStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeStartedType, + payload: ThreadRealtimeStartedPayload, +}); +export type ProviderRuntimeThreadRealtimeStartedEvent = + typeof ProviderRuntimeThreadRealtimeStartedEvent.Type; + +const ProviderRuntimeThreadRealtimeItemAddedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeItemAddedType, + payload: ThreadRealtimeItemAddedPayload, +}); +export type ProviderRuntimeThreadRealtimeItemAddedEvent = + typeof ProviderRuntimeThreadRealtimeItemAddedEvent.Type; + +const ProviderRuntimeThreadRealtimeAudioDeltaEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeAudioDeltaType, + payload: ThreadRealtimeAudioDeltaPayload, +}); +export type ProviderRuntimeThreadRealtimeAudioDeltaEvent = + typeof ProviderRuntimeThreadRealtimeAudioDeltaEvent.Type; + +const ProviderRuntimeThreadRealtimeErrorEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeErrorType, + payload: ThreadRealtimeErrorPayload, +}); +export type ProviderRuntimeThreadRealtimeErrorEvent = + typeof ProviderRuntimeThreadRealtimeErrorEvent.Type; + +const ProviderRuntimeThreadRealtimeClosedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeClosedType, + payload: ThreadRealtimeClosedPayload, +}); +export type ProviderRuntimeThreadRealtimeClosedEvent = + typeof ProviderRuntimeThreadRealtimeClosedEvent.Type; + +const ProviderRuntimeTurnStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnStartedType, + payload: TurnStartedPayload, +}); +export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; + +const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnCompletedType, + payload: TurnCompletedPayload, +}); +export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; + +const ProviderRuntimeTurnAbortedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnAbortedType, + payload: TurnAbortedPayload, +}); +export type ProviderRuntimeTurnAbortedEvent = typeof ProviderRuntimeTurnAbortedEvent.Type; + +const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnPlanUpdatedType, + payload: TurnPlanUpdatedPayload, +}); +export type ProviderRuntimeTurnPlanUpdatedEvent = typeof ProviderRuntimeTurnPlanUpdatedEvent.Type; + +const ProviderRuntimeTurnProposedDeltaEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnProposedDeltaType, + payload: TurnProposedDeltaPayload, +}); +export type ProviderRuntimeTurnProposedDeltaEvent = + typeof ProviderRuntimeTurnProposedDeltaEvent.Type; + +const ProviderRuntimeTurnProposedCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnProposedCompletedType, + payload: TurnProposedCompletedPayload, +}); +export type ProviderRuntimeTurnProposedCompletedEvent = + typeof ProviderRuntimeTurnProposedCompletedEvent.Type; + +const ProviderRuntimeTurnDiffUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnDiffUpdatedType, + payload: TurnDiffUpdatedPayload, +}); +export type ProviderRuntimeTurnDiffUpdatedEvent = typeof ProviderRuntimeTurnDiffUpdatedEvent.Type; + +const ProviderRuntimeItemStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ItemStartedType, + payload: ItemLifecyclePayload, +}); +export type ProviderRuntimeItemStartedEvent = typeof ProviderRuntimeItemStartedEvent.Type; + +const ProviderRuntimeItemUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ItemUpdatedType, + payload: ItemLifecyclePayload, +}); +export type ProviderRuntimeItemUpdatedEvent = typeof ProviderRuntimeItemUpdatedEvent.Type; + +const ProviderRuntimeItemCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ItemCompletedType, + payload: ItemLifecyclePayload, +}); +export type ProviderRuntimeItemCompletedEvent = typeof ProviderRuntimeItemCompletedEvent.Type; + +const ProviderRuntimeContentDeltaEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ContentDeltaType, + payload: ContentDeltaPayload, +}); +export type ProviderRuntimeContentDeltaEvent = typeof ProviderRuntimeContentDeltaEvent.Type; + +const ProviderRuntimeRequestOpenedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: RequestOpenedType, + payload: RequestOpenedPayload, +}); +export type ProviderRuntimeRequestOpenedEvent = typeof ProviderRuntimeRequestOpenedEvent.Type; + +const ProviderRuntimeRequestResolvedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: RequestResolvedType, + payload: RequestResolvedPayload, +}); +export type ProviderRuntimeRequestResolvedEvent = typeof ProviderRuntimeRequestResolvedEvent.Type; + +const ProviderRuntimeUserInputRequestedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: UserInputRequestedType, + payload: UserInputRequestedPayload, +}); +export type ProviderRuntimeUserInputRequestedEvent = + typeof ProviderRuntimeUserInputRequestedEvent.Type; + +const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: UserInputResolvedType, + payload: UserInputResolvedPayload, +}); +export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type; + +const ProviderRuntimeTaskStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskStartedType, + payload: TaskStartedPayload, +}); +export type ProviderRuntimeTaskStartedEvent = typeof ProviderRuntimeTaskStartedEvent.Type; + +const ProviderRuntimeTaskProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskProgressType, + payload: TaskProgressPayload, +}); +export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type; + +const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskCompletedType, + payload: TaskCompletedPayload, +}); +export type ProviderRuntimeTaskCompletedEvent = typeof ProviderRuntimeTaskCompletedEvent.Type; + +const ProviderRuntimeHookStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookStartedType, + payload: HookStartedPayload, +}); +export type ProviderRuntimeHookStartedEvent = typeof ProviderRuntimeHookStartedEvent.Type; + +const ProviderRuntimeHookProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookProgressType, + payload: HookProgressPayload, +}); +export type ProviderRuntimeHookProgressEvent = typeof ProviderRuntimeHookProgressEvent.Type; + +const ProviderRuntimeHookCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookCompletedType, + payload: HookCompletedPayload, +}); +export type ProviderRuntimeHookCompletedEvent = typeof ProviderRuntimeHookCompletedEvent.Type; + +const ProviderRuntimeToolProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ToolProgressType, + payload: ToolProgressPayload, +}); +export type ProviderRuntimeToolProgressEvent = typeof ProviderRuntimeToolProgressEvent.Type; + +const ProviderRuntimeToolSummaryEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ToolSummaryType, + payload: ToolSummaryPayload, +}); +export type ProviderRuntimeToolSummaryEvent = typeof ProviderRuntimeToolSummaryEvent.Type; + +const ProviderRuntimeAuthStatusEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AuthStatusType, + payload: AuthStatusPayload, +}); +export type ProviderRuntimeAuthStatusEvent = typeof ProviderRuntimeAuthStatusEvent.Type; + +const ProviderRuntimeAccountUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AccountUpdatedType, + payload: AccountUpdatedPayload, +}); +export type ProviderRuntimeAccountUpdatedEvent = typeof ProviderRuntimeAccountUpdatedEvent.Type; + +const ProviderRuntimeAccountRateLimitsUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AccountRateLimitsUpdatedType, + payload: AccountRateLimitsUpdatedPayload, +}); +export type ProviderRuntimeAccountRateLimitsUpdatedEvent = + typeof ProviderRuntimeAccountRateLimitsUpdatedEvent.Type; + +const ProviderRuntimeMcpStatusUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: McpStatusUpdatedType, + payload: McpStatusUpdatedPayload, +}); +export type ProviderRuntimeMcpStatusUpdatedEvent = typeof ProviderRuntimeMcpStatusUpdatedEvent.Type; + +const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: McpOauthCompletedType, + payload: McpOauthCompletedPayload, +}); +export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type; + +const ProviderRuntimeModelReroutedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ModelReroutedType, + payload: ModelReroutedPayload, +}); +export type ProviderRuntimeModelReroutedEvent = typeof ProviderRuntimeModelReroutedEvent.Type; + +const ProviderRuntimeConfigWarningEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ConfigWarningType, + payload: ConfigWarningPayload, +}); +export type ProviderRuntimeConfigWarningEvent = typeof ProviderRuntimeConfigWarningEvent.Type; + +const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: DeprecationNoticeType, + payload: DeprecationNoticePayload, +}); +export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type; + +const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: FilesPersistedType, + payload: FilesPersistedPayload, +}); +export type ProviderRuntimeFilesPersistedEvent = typeof ProviderRuntimeFilesPersistedEvent.Type; + +const ProviderRuntimeWarningEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: RuntimeWarningType, + payload: RuntimeWarningPayload, +}); +export type ProviderRuntimeWarningEvent = typeof ProviderRuntimeWarningEvent.Type; + +const ProviderRuntimeErrorEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: RuntimeErrorType, + payload: RuntimeErrorPayload, }); export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; -export const ProviderRuntimeEvent = Schema.Union([ +export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeSessionStartedEvent, + ProviderRuntimeSessionConfiguredEvent, + ProviderRuntimeSessionStateChangedEvent, ProviderRuntimeSessionExitedEvent, ProviderRuntimeThreadStartedEvent, + ProviderRuntimeThreadStateChangedEvent, + ProviderRuntimeThreadMetadataUpdatedEvent, + ProviderRuntimeThreadTokenUsageUpdatedEvent, + ProviderRuntimeThreadRealtimeStartedEvent, + ProviderRuntimeThreadRealtimeItemAddedEvent, + ProviderRuntimeThreadRealtimeAudioDeltaEvent, + ProviderRuntimeThreadRealtimeErrorEvent, + ProviderRuntimeThreadRealtimeClosedEvent, ProviderRuntimeTurnStartedEvent, ProviderRuntimeTurnCompletedEvent, - ProviderRuntimeMessageDeltaEvent, - ProviderRuntimeMessageCompletedEvent, - ProviderRuntimeToolStartedEvent, - ProviderRuntimeToolCompletedEvent, - ProviderRuntimeApprovalRequestedEvent, - ProviderRuntimeApprovalResolvedEvent, - ProviderRuntimeCheckpointCapturedEvent, + ProviderRuntimeTurnAbortedEvent, + ProviderRuntimeTurnPlanUpdatedEvent, + ProviderRuntimeTurnProposedDeltaEvent, + ProviderRuntimeTurnProposedCompletedEvent, + ProviderRuntimeTurnDiffUpdatedEvent, + ProviderRuntimeItemStartedEvent, + ProviderRuntimeItemUpdatedEvent, + ProviderRuntimeItemCompletedEvent, + ProviderRuntimeContentDeltaEvent, + ProviderRuntimeRequestOpenedEvent, + ProviderRuntimeRequestResolvedEvent, + ProviderRuntimeUserInputRequestedEvent, + ProviderRuntimeUserInputResolvedEvent, + ProviderRuntimeTaskStartedEvent, + ProviderRuntimeTaskProgressEvent, + ProviderRuntimeTaskCompletedEvent, + ProviderRuntimeHookStartedEvent, + ProviderRuntimeHookProgressEvent, + ProviderRuntimeHookCompletedEvent, + ProviderRuntimeToolProgressEvent, + ProviderRuntimeToolSummaryEvent, + ProviderRuntimeAuthStatusEvent, + ProviderRuntimeAccountUpdatedEvent, + ProviderRuntimeAccountRateLimitsUpdatedEvent, + ProviderRuntimeMcpStatusUpdatedEvent, + ProviderRuntimeMcpOauthCompletedEvent, + ProviderRuntimeModelReroutedEvent, + ProviderRuntimeConfigWarningEvent, + ProviderRuntimeDeprecationNoticeEvent, + ProviderRuntimeFilesPersistedEvent, + ProviderRuntimeWarningEvent, ProviderRuntimeErrorEvent, ]); -export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type; +export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type; + +export const ProviderRuntimeEvent = ProviderRuntimeEventV2; +export type ProviderRuntimeEvent = ProviderRuntimeEventV2; + +// Compatibility aliases for call sites still importing legacy names. +const ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; +export type ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; +const ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; +export type ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; +const ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; +export type ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; +const ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; +export type ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; +const ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; +export type ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; +const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; +export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; + +// Legacy helper aliases retained for adapters/tests. +const ProviderRuntimeToolKind = Schema.Literals([ + "command", + "file-read", + "file-change", + "other", +]); +export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; + +export const ProviderRuntimeTurnStatus = RuntimeTurnState; +export type ProviderRuntimeTurnStatus = RuntimeTurnState; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5607be35c4..96ea90c1f5 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -4,18 +4,16 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; -export const KeybindingsMalformedConfigIssue = Schema.Struct({ +const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), message: TrimmedNonEmptyString, }); -export type KeybindingsMalformedConfigIssue = typeof KeybindingsMalformedConfigIssue.Type; -export const KeybindingsInvalidEntryIssue = Schema.Struct({ +const KeybindingsInvalidEntryIssue = Schema.Struct({ kind: Schema.Literal("keybindings.invalid-entry"), message: TrimmedNonEmptyString, index: Schema.Number, }); -export type KeybindingsInvalidEntryIssue = typeof KeybindingsInvalidEntryIssue.Type; export const ServerConfigIssue = Schema.Union([ KeybindingsMalformedConfigIssue, @@ -23,8 +21,7 @@ export const ServerConfigIssue = Schema.Union([ ]); export type ServerConfigIssue = typeof ServerConfigIssue.Type; -export const ServerConfigIssues = Schema.Array(ServerConfigIssue); -export type ServerConfigIssues = typeof ServerConfigIssues.Type; +const ServerConfigIssues = Schema.Array(ServerConfigIssue); export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; @@ -46,8 +43,7 @@ export const ServerProviderStatus = Schema.Struct({ }); export type ServerProviderStatus = typeof ServerProviderStatus.Type; -export const ServerProviderStatuses = Schema.Array(ServerProviderStatus); -export type ServerProviderStatuses = typeof ServerProviderStatuses.Type; +const ServerProviderStatuses = Schema.Array(ServerProviderStatus); export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 1dea8963ec..e7c20242b1 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -28,7 +28,7 @@ export const TerminalThreadInput = Schema.Struct({ }); export type TerminalThreadInput = Schema.Codec.Encoded; -export const TerminalSessionInput = Schema.Struct({ +const TerminalSessionInput = Schema.Struct({ ...TerminalThreadInput.fields, terminalId: TerminalIdWithDefaultSchema, }); @@ -96,43 +96,43 @@ const TerminalEventBaseSchema = Schema.Struct({ createdAt: Schema.String, }); -export const TerminalStartedEvent = Schema.Struct({ +const TerminalStartedEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("started"), snapshot: TerminalSessionSnapshot, }); -export const TerminalOutputEvent = Schema.Struct({ +const TerminalOutputEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("output"), data: Schema.String, }); -export const TerminalExitedEvent = Schema.Struct({ +const TerminalExitedEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("exited"), exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), }); -export const TerminalErrorEvent = Schema.Struct({ +const TerminalErrorEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("error"), message: Schema.String.check(Schema.isNonEmpty()), }); -export const TerminalClearedEvent = Schema.Struct({ +const TerminalClearedEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("cleared"), }); -export const TerminalRestartedEvent = Schema.Struct({ +const TerminalRestartedEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("restarted"), snapshot: TerminalSessionSnapshot, }); -export const TerminalActivityEvent = Schema.Struct({ +const TerminalActivityEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("activity"), hasRunningSubprocess: Schema.Boolean, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 7f94615bdd..1100b4f9df 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -29,7 +29,7 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput } from "./project"; +import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -40,6 +40,7 @@ export const WS_METHODS = { projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", + projectsWriteFile: "projects.writeFile", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -88,7 +89,7 @@ const tagRequestBody = { + it("maps known aliases to canonical slugs", () => { + expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + }); + + it("returns null for empty or missing values", () => { + expect(normalizeModelSlug("")).toBeNull(); + expect(normalizeModelSlug(" ")).toBeNull(); + expect(normalizeModelSlug(null)).toBeNull(); + expect(normalizeModelSlug(undefined)).toBeNull(); + }); + + it("preserves non-aliased model slugs", () => { + expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); + expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); + }); + + it("uses provider-specific aliases", () => { + expect(normalizeModelSlug("sonnet", "claudeCode")).toBe("claude-sonnet-4-6"); + expect(normalizeModelSlug("opus-4.6", "claudeCode")).toBe("claude-opus-4-6"); + expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe( + "claude-haiku-4-5", + ); + expect(normalizeModelSlug("composer", "cursor")).toBe("composer-1.5"); + expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe( + "gpt-5.3-codex-spark-preview", + ); + expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1"); + expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe( + "sonnet-4.6-thinking", + ); + }); +}); + +describe("resolveModelSlug", () => { + it("returns default only when the model is missing", () => { + expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); + expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); + }); + + it("preserves unknown custom models", () => { + expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); + expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); + }); + + it("resolves only supported model options", () => { + for (const model of MODEL_OPTIONS) { + expect(resolveModelSlug(model.slug)).toBe(model.slug); + } + }); + + it("supports provider-aware resolution", () => { + expect(resolveModelSlugForProvider("claudeCode", undefined)).toBe( + DEFAULT_MODEL_BY_PROVIDER.claudeCode, + ); + expect(resolveModelSlugForProvider("claudeCode", "sonnet")).toBe("claude-sonnet-4-6"); + expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe( + DEFAULT_MODEL_BY_PROVIDER.claudeCode, + ); + expect(resolveModelSlugForProvider("cursor", undefined)).toBe( + DEFAULT_MODEL_BY_PROVIDER.cursor, + ); + expect(resolveModelSlugForProvider("cursor", "composer")).toBe("composer-1.5"); + expect(resolveModelSlugForProvider("cursor", "gpt-5.3-codex-high-fast")).toBe( + "gpt-5.3-codex-high-fast", + ); + expect(resolveModelSlugForProvider("cursor", "claude-sonnet-4-6")).toBe( + DEFAULT_MODEL_BY_PROVIDER.cursor, + ); + }); + + it("keeps codex defaults for backward compatibility", () => { + expect(getDefaultModel()).toBe(DEFAULT_MODEL); + expect(getModelOptions()).toEqual(MODEL_OPTIONS); + expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode); + expect(getModelOptions("cursor")).toEqual(MODEL_OPTIONS_BY_PROVIDER.cursor); + expect(getCursorModelFamilyOptions()).toEqual(CURSOR_MODEL_FAMILY_OPTIONS); + }); +}); + +describe("cursor model selection", () => { + it("includes the expected cursor reasoning levels and families", () => { + expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]); + expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex"); + expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6"); + }); + + it("parses codex reasoning and fast mode variants", () => { + expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toEqual({ + family: "gpt-5.3-codex", + reasoning: "high", + fast: true, + thinking: false, + }); + expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual( + parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor), + ); + }); + + it("parses and resolves thinking variants", () => { + expect(parseCursorModelSelection("sonnet-4.6-thinking")).toEqual({ + family: "sonnet-4.6", + reasoning: "normal", + fast: false, + thinking: true, + }); + expect( + resolveCursorModelFromSelection({ + family: "sonnet-4.6", + thinking: true, + }), + ).toBe("sonnet-4.6-thinking"); + }); + + it("resolves codex family selections into concrete model ids", () => { + expect( + resolveCursorModelFromSelection({ + family: "gpt-5.3-codex", + reasoning: "xhigh", + fast: true, + }), + ).toBe("gpt-5.3-codex-xhigh-fast"); + }); +}); + +describe("getReasoningEffortOptions", () => { + it("returns codex reasoning options for codex", () => { + expect(getReasoningEffortOptions("codex")).toEqual( + REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, + ); + }); + + it("returns no reasoning options for claudeCode", () => { + expect(getReasoningEffortOptions("claudeCode")).toEqual([]); + }); + + it("returns no reasoning options for cursor", () => { + expect(getReasoningEffortOptions("cursor")).toEqual([]); + }); +}); + +describe("getDefaultReasoningEffort", () => { + it("returns provider-scoped defaults", () => { + expect(getDefaultReasoningEffort("codex")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex, + ); + expect(getDefaultReasoningEffort("claudeCode")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeCode, + ); + expect(getDefaultReasoningEffort("cursor")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor, + ); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts new file mode 100644 index 0000000000..7343a2c50c --- /dev/null +++ b/packages/shared/src/model.ts @@ -0,0 +1,280 @@ +import { + CODEX_REASONING_EFFORT_OPTIONS, + CURSOR_MODEL_FAMILY_OPTIONS, + CURSOR_REASONING_OPTIONS, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, + MODEL_SLUG_ALIASES_BY_PROVIDER, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, + type CodexReasoningEffort, + type CursorModelFamily, + type CursorModelSlug, + type CursorReasoningOption, + type ModelSlug, + type ProviderKind, +} from "../../contracts/src"; + +type CursorModelCapability = { + readonly supportsReasoning: boolean; + readonly supportsFast: boolean; + readonly supportsThinking: boolean; + readonly defaultReasoning: CursorReasoningOption; + readonly defaultThinking: boolean; +}; + +const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record = { + auto: { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, + "composer-1.5": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, + "composer-1": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, + "gpt-5.3-codex": { + supportsReasoning: true, + supportsFast: true, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, + "gpt-5.3-codex-spark-preview": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, + "opus-4.6": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + defaultReasoning: "normal", + defaultThinking: true, + }, + "opus-4.5": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + defaultReasoning: "normal", + defaultThinking: true, + }, + "sonnet-4.6": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + defaultReasoning: "normal", + defaultThinking: true, + }, + "gemini-3.1-pro": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + defaultReasoning: "normal", + defaultThinking: false, + }, +}; + +const MODEL_SLUG_SET_BY_PROVIDER: Record> = { + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), + codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), +}; + +const CURSOR_MODEL_FAMILY_SET = new Set( + CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), +); + +export interface CursorModelSelection { + readonly family: CursorModelFamily; + readonly reasoning: CursorReasoningOption; + readonly fast: boolean; + readonly thinking: boolean; +} + +export function getModelOptions(provider: ProviderKind = "codex") { + return MODEL_OPTIONS_BY_PROVIDER[provider]; +} + +export function getCursorModelFamilyOptions() { + return CURSOR_MODEL_FAMILY_OPTIONS; +} + +export function getCursorModelCapabilities(family: CursorModelFamily) { + return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; +} + +function fallbackCursorModelFamily(): CursorModelFamily { + const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor); + return fallback.family; +} + +function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily { + const normalized = normalizeModelSlug(model, "cursor"); + if (!normalized) { + return fallbackCursorModelFamily(); + } + + if ( + normalized === "gpt-5.3-codex" || + normalized === "gpt-5.3-codex-fast" || + normalized === "gpt-5.3-codex-low" || + normalized === "gpt-5.3-codex-low-fast" || + normalized === "gpt-5.3-codex-high" || + normalized === "gpt-5.3-codex-high-fast" || + normalized === "gpt-5.3-codex-xhigh" || + normalized === "gpt-5.3-codex-xhigh-fast" + ) { + return "gpt-5.3-codex"; + } + + if (normalized === "sonnet-4.6-thinking") { + return "sonnet-4.6"; + } + if (normalized === "opus-4.6-thinking") { + return "opus-4.6"; + } + if (normalized === "opus-4.5-thinking") { + return "opus-4.5"; + } + + return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily) + ? (normalized as CursorModelFamily) + : fallbackCursorModelFamily(); +} + +function resolveCursorReasoning(model: CursorModelSlug): CursorReasoningOption { + if (model.includes("-xhigh")) return "xhigh"; + if (model.includes("-high")) return "high"; + if (model.includes("-low")) return "low"; + return "normal"; +} + +export function parseCursorModelSelection(model: string | null | undefined): CursorModelSelection { + const family = resolveCursorModelFamily(model); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; + + if (capability.supportsReasoning) { + return { + family, + reasoning: resolveCursorReasoning(normalized), + fast: normalized.endsWith("-fast"), + thinking: false, + }; + } + + if (capability.supportsThinking) { + return { + family, + reasoning: capability.defaultReasoning, + fast: false, + thinking: normalized.endsWith("-thinking"), + }; + } + + return { + family, + reasoning: capability.defaultReasoning, + fast: false, + thinking: capability.defaultThinking, + }; +} + +export function resolveCursorModelFromSelection(input: { + readonly family: CursorModelFamily; + readonly reasoning?: CursorReasoningOption | null; + readonly fast?: boolean; + readonly thinking?: boolean; +}): CursorModelSlug { + const family = resolveCursorModelFamily(input.family); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + + if (capability.supportsReasoning) { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`; + const fastSuffix = input.fast ? "-fast" : ""; + const candidate = `${family}${reasoningSuffix}${fastSuffix}`; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (capability.supportsThinking) { + const candidate = input.thinking ? `${family}-thinking` : family; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + return resolveModelSlugForProvider("cursor", family) as CursorModelSlug; +} + +export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { + return DEFAULT_MODEL_BY_PROVIDER[provider]; +} + +export function normalizeModelSlug( + model: string | null | undefined, + provider: ProviderKind = "codex", +): ModelSlug | null { + if (typeof model !== "string") { + return null; + } + + const trimmed = model.trim(); + if (!trimmed) { + return null; + } + + return MODEL_SLUG_ALIASES_BY_PROVIDER[provider][trimmed] ?? (trimmed as ModelSlug); +} + +export function resolveModelSlug( + model: string | null | undefined, + provider: ProviderKind = "codex", +): ModelSlug { + const normalized = normalizeModelSlug(model, provider); + if (!normalized) { + return DEFAULT_MODEL_BY_PROVIDER[provider]; + } + + return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) + ? normalized + : DEFAULT_MODEL_BY_PROVIDER[provider]; +} + +export function resolveModelSlugForProvider( + provider: ProviderKind, + model: string | null | undefined, +): ModelSlug { + return resolveModelSlug(model, provider); +} + +export function getReasoningEffortOptions( + provider: ProviderKind = "codex", +): ReadonlyArray { + return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; +} + +export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; +export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null; +export function getDefaultReasoningEffort( + provider: ProviderKind = "codex", +): CodexReasoningEffort | null { + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; +} + +export { CODEX_REASONING_EFFORT_OPTIONS }; diff --git a/scripts/cursor-acp-probe.mjs b/scripts/cursor-acp-probe.mjs new file mode 100644 index 0000000000..ee1c91f821 --- /dev/null +++ b/scripts/cursor-acp-probe.mjs @@ -0,0 +1,511 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; + +const DEFAULT_PROMPT_TIMEOUT_MS = 120_000; +const CANCEL_AFTER_MS = 1_500; + +function parseArgv(argv) { + const parsed = { + outputDir: "", + workspace: process.cwd(), + model: "", + permissionOption: "allow-once", + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + const next = argv[i + 1]; + if ((token === "--output-dir" || token === "-o") && next) { + parsed.outputDir = next; + i += 1; + continue; + } + if ((token === "--workspace" || token === "-w") && next) { + parsed.workspace = path.resolve(next); + i += 1; + continue; + } + if ((token === "--model" || token === "-m") && next) { + parsed.model = next; + i += 1; + continue; + } + if (token === "--permission-option" && next) { + parsed.permissionOption = next; + i += 1; + } + } + + return parsed; +} + +function nowIso() { + return new Date().toISOString(); +} + +function safeJsonParse(line) { + try { + return { ok: true, value: JSON.parse(line) }; + } catch (error) { + return { ok: false, error }; + } +} + +class AcpProbeClient { + #child; + #stdoutRl; + #stderrRl; + #nextId = 1; + #closed = false; + #pending = new Map(); + #onMessage; + #onServerRequest; + + constructor({ onMessage, onServerRequest }) { + this.#onMessage = onMessage; + this.#onServerRequest = onServerRequest; + + this.#child = spawn("agent", ["acp"], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + NO_COLOR: "1", + }, + }); + + this.#stdoutRl = readline.createInterface({ input: this.#child.stdout }); + this.#stderrRl = readline.createInterface({ input: this.#child.stderr }); + + this.#stdoutRl.on("line", (line) => this.#handleStdoutLine(line)); + this.#stderrRl.on("line", (line) => { + this.#onMessage({ + ts: nowIso(), + channel: "stderr", + line, + }); + }); + + this.#child.once("exit", (code, signal) => { + this.#closed = true; + const reason = `ACP process exited (code=${String(code)}, signal=${String(signal)})`; + for (const [id, pending] of this.#pending.entries()) { + this.#pending.delete(id); + pending.reject(new Error(reason)); + } + this.#onMessage({ + ts: nowIso(), + channel: "lifecycle", + event: "exit", + code, + signal, + }); + }); + } + + async close() { + if (this.#closed) return; + try { + this.#child.stdin.end(); + } catch { + // ignored + } + this.#child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!this.#closed) { + this.#child.kill("SIGKILL"); + } + } + + async send(method, params, { timeoutMs = DEFAULT_PROMPT_TIMEOUT_MS } = {}) { + if (this.#closed) { + throw new Error("Cannot send: ACP process is already closed."); + } + const id = this.#nextId; + this.#nextId += 1; + + const message = { + jsonrpc: "2.0", + id, + method, + params, + }; + + this.#onMessage({ + ts: nowIso(), + channel: "client->server", + message, + }); + this.#child.stdin.write(`${JSON.stringify(message)}\n`); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`Timed out waiting for response to '${method}' (id=${id}).`)); + }, timeoutMs); + + this.#pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + }); + } + + #respond(id, result) { + if (this.#closed) return; + const message = { + jsonrpc: "2.0", + id, + result, + }; + this.#onMessage({ + ts: nowIso(), + channel: "client->server", + message, + }); + this.#child.stdin.write(`${JSON.stringify(message)}\n`); + } + + #handleStdoutLine(line) { + const parsed = safeJsonParse(line); + if (!parsed.ok) { + this.#onMessage({ + ts: nowIso(), + channel: "stdout-non-json", + line, + }); + return; + } + + const message = parsed.value; + this.#onMessage({ + ts: nowIso(), + channel: "server->client", + message, + }); + + if ( + Object.prototype.hasOwnProperty.call(message, "id") && + (Object.prototype.hasOwnProperty.call(message, "result") || + Object.prototype.hasOwnProperty.call(message, "error")) && + !Object.prototype.hasOwnProperty.call(message, "method") + ) { + const pending = this.#pending.get(message.id); + if (!pending) return; + this.#pending.delete(message.id); + if (Object.prototype.hasOwnProperty.call(message, "error")) { + pending.reject(message.error); + return; + } + pending.resolve(message.result); + return; + } + + if ( + Object.prototype.hasOwnProperty.call(message, "id") && + Object.prototype.hasOwnProperty.call(message, "method") + ) { + this.#onServerRequest?.(message, (result) => this.#respond(message.id, result)); + } + } +} + +function summarizeTranscript(entries) { + const summary = { + counts: { + notificationsByMethod: {}, + sessionUpdateByType: {}, + serverRequestsByMethod: {}, + permissionDecisions: {}, + }, + samples: { + initializeResult: null, + authenticateResult: null, + sessionNewResult: null, + sessionPromptResultByScenario: {}, + sessionUpdateByType: {}, + serverRequestByMethod: {}, + }, + stderr: [], + }; + + for (const entry of entries) { + if (entry.channel === "stderr") { + summary.stderr.push(entry.line); + continue; + } + if (entry.channel !== "server->client") continue; + + const message = entry.message; + if (!message || typeof message !== "object") continue; + + if (typeof message.method === "string" && !Object.prototype.hasOwnProperty.call(message, "id")) { + summary.counts.notificationsByMethod[message.method] = + (summary.counts.notificationsByMethod[message.method] ?? 0) + 1; + if (message.method === "session/update") { + const updateType = message.params?.update?.sessionUpdate; + if (typeof updateType === "string") { + summary.counts.sessionUpdateByType[updateType] = + (summary.counts.sessionUpdateByType[updateType] ?? 0) + 1; + if (!summary.samples.sessionUpdateByType[updateType]) { + summary.samples.sessionUpdateByType[updateType] = message; + } + } + } + continue; + } + + if (typeof message.method === "string" && Object.prototype.hasOwnProperty.call(message, "id")) { + summary.counts.serverRequestsByMethod[message.method] = + (summary.counts.serverRequestsByMethod[message.method] ?? 0) + 1; + if (!summary.samples.serverRequestByMethod[message.method]) { + summary.samples.serverRequestByMethod[message.method] = message; + } + continue; + } + } + + for (const entry of entries) { + if (entry.channel !== "scenario-result") continue; + if (entry.scenario === "initialize" && !summary.samples.initializeResult) { + summary.samples.initializeResult = entry.result; + continue; + } + if (entry.scenario === "authenticate" && !summary.samples.authenticateResult) { + summary.samples.authenticateResult = entry.result; + continue; + } + if (entry.scenario === "session/new" && !summary.samples.sessionNewResult) { + summary.samples.sessionNewResult = entry.result; + continue; + } + if (entry.scenarioName) { + summary.samples.sessionPromptResultByScenario[entry.scenarioName] = entry.result; + } + } + + for (const entry of entries) { + if (entry.channel !== "permission-decision") continue; + const optionId = entry.optionId; + summary.counts.permissionDecisions[optionId] = + (summary.counts.permissionDecisions[optionId] ?? 0) + 1; + } + + return summary; +} + +async function run() { + const args = parseArgv(process.argv.slice(2)); + const allowedPermissionOptions = new Set(["allow-once", "allow-always", "reject-once"]); + if (!allowedPermissionOptions.has(args.permissionOption)) { + throw new Error( + `Invalid --permission-option '${args.permissionOption}'. Expected one of: ${Array.from(allowedPermissionOptions).join(", ")}`, + ); + } + const stamp = nowIso().replaceAll(":", "-"); + const outputDir = args.outputDir + ? path.resolve(args.outputDir) + : path.join(process.cwd(), ".tmp", "acp-probe", stamp); + await fs.mkdir(outputDir, { recursive: true }); + + const transcript = []; + const pushEntry = (entry) => transcript.push(entry); + + let activeScenarioName = null; + + const client = new AcpProbeClient({ + onMessage: (entry) => { + if ( + activeScenarioName && + entry.channel === "server->client" && + entry.message?.method === "session/update" + ) { + transcript.push({ ...entry, scenarioName: activeScenarioName }); + return; + } + transcript.push(entry); + }, + onServerRequest: (message, respond) => { + if (message.method === "session/request_permission") { + const defaultChoice = args.permissionOption; + pushEntry({ + ts: nowIso(), + channel: "permission-decision", + requestId: message.id, + optionId: defaultChoice, + params: message.params, + }); + respond({ + outcome: { + outcome: "selected", + optionId: defaultChoice, + }, + }); + return; + } + + respond({ + outcome: { + outcome: "selected", + optionId: "deny", + }, + }); + pushEntry({ + ts: nowIso(), + channel: "server-request-unhandled", + method: message.method, + requestId: message.id, + params: message.params, + }); + }, + }); + + try { + const initializeResult = await client.send("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "t3-cursor-acp-probe", + version: "0.1.0", + }, + }); + pushEntry({ + ts: nowIso(), + channel: "scenario-result", + scenario: "initialize", + result: initializeResult, + }); + + const authenticateResult = await client.send("authenticate", { + methodId: "cursor_login", + }); + pushEntry({ + ts: nowIso(), + channel: "scenario-result", + scenario: "authenticate", + result: authenticateResult, + }); + + const sessionParams = { + cwd: args.workspace, + mcpServers: [], + ...(args.model ? { model: args.model } : {}), + }; + const sessionResult = await client.send("session/new", sessionParams); + pushEntry({ + ts: nowIso(), + channel: "scenario-result", + scenario: "session/new", + result: sessionResult, + }); + + const sessionId = sessionResult?.sessionId; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new Error(`Missing sessionId from session/new response: ${JSON.stringify(sessionResult)}`); + } + + const scenarios = [ + { + name: "hello", + prompt: "Say hello in one sentence.", + }, + { + name: "tooling", + prompt: + "Use tools to run `pwd` and then `ls -1 | head -n 8`, and summarize what you found in one paragraph.", + }, + { + name: "cancel", + prompt: + "Think for a while and draft a long detailed migration plan with at least 20 bullet points before answering.", + cancelAfterMs: CANCEL_AFTER_MS, + }, + ]; + + for (const scenario of scenarios) { + activeScenarioName = scenario.name; + const promptParams = { + sessionId, + prompt: [{ type: "text", text: scenario.prompt }], + }; + + const promptPromise = client.send("session/prompt", promptParams, { + timeoutMs: DEFAULT_PROMPT_TIMEOUT_MS, + }); + + if (scenario.cancelAfterMs) { + setTimeout(() => { + client + .send("session/cancel", { sessionId }, { timeoutMs: 15_000 }) + .then((cancelResult) => { + pushEntry({ + ts: nowIso(), + channel: "scenario-result", + scenario: "session/cancel", + scenarioName: scenario.name, + result: cancelResult, + }); + }) + .catch((error) => { + pushEntry({ + ts: nowIso(), + channel: "scenario-error", + scenario: "session/cancel", + scenarioName: scenario.name, + error: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : JSON.stringify(error), + }); + }); + }, scenario.cancelAfterMs); + } + + const promptResult = await promptPromise; + pushEntry({ + ts: nowIso(), + channel: "scenario-result", + scenario: "session/prompt", + scenarioName: scenario.name, + result: promptResult, + }); + activeScenarioName = null; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } finally { + await client.close(); + } + + const summary = summarizeTranscript(transcript); + const transcriptPath = path.join(outputDir, "transcript.ndjson"); + const summaryPath = path.join(outputDir, "summary.json"); + + await fs.writeFile( + transcriptPath, + `${transcript.map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + + process.stdout.write(`ACP probe complete.\n`); + process.stdout.write(` outputDir: ${outputDir}\n`); + process.stdout.write(` transcript: ${transcriptPath}\n`); + process.stdout.write(` summary: ${summaryPath}\n`); +} + +run().catch((error) => { + process.stderr.write(`ACP probe failed: ${String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 61809a40f3..704a285414 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); }), ); + + it.effect("does not force websocket logging on in dev mode when unset", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { + T3CODE_LOG_WS_EVENTS: "keep-me-out", + }, + serverOffset: 0, + webOffset: 0, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_MODE, "web"); + assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined); + }), + ); + + it.effect("forwards explicit websocket logging false without coercing it away", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: false, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 9880cb3ad1..faab7d4ae1 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -189,9 +189,6 @@ export function createDevRunnerEnv({ if (mode === "dev") { output.T3CODE_MODE = "web"; - if (logWebSocketEvents === undefined) { - output.T3CODE_LOG_WS_EVENTS = "1"; - } delete output.T3CODE_DESKTOP_WS_URL; } @@ -350,6 +347,35 @@ interface DevRunnerCliInput { readonly turboArgs: ReadonlyArray; } +const readOptionalBooleanEnv = (name: string): boolean | undefined => { + const value = process.env[name]; + if (value === undefined) { + return undefined; + } + if (value === "1" || value.toLowerCase() === "true") { + return true; + } + if (value === "0" || value.toLowerCase() === "false") { + return false; + } + return undefined; +}; + +const resolveOptionalBooleanOverride = ( + explicitValue: boolean | undefined, + envValue: boolean | undefined, +): boolean | undefined => { + if (explicitValue === true) { + return true; + } + + if (explicitValue === false) { + return envValue; + } + + return envValue; +}; + export function runDevRunnerWithInput(input: DevRunnerCliInput) { return Effect.gen(function* () { const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe( @@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { }), }); + const envOverrides = { + noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"), + autoBootstrapProjectFromCwd: readOptionalBooleanEnv( + "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", + ), + logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"), + }; + const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, @@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { webOffset, stateDir: input.stateDir, authToken: input.authToken, - noBrowser: input.noBrowser, - autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, - logWebSocketEvents: input.logWebSocketEvents, + noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), + autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( + input.autoBootstrapProjectFromCwd, + envOverrides.autoBootstrapProjectFromCwd, + ), + logWebSocketEvents: resolveOptionalBooleanOverride( + input.logWebSocketEvents, + envOverrides.logWebSocketEvents, + ), host: input.host, port: input.port, devUrl: input.devUrl,