diff --git a/.plans/18-cursor-agent-provider.md b/.plans/18-cursor-agent-provider.md new file mode 100644 index 0000000000..452592e68d --- /dev/null +++ b/.plans/18-cursor-agent-provider.md @@ -0,0 +1,327 @@ +# Plan: Cursor ACP (`agent acp`) Provider Integration + +## Goal + +Add Cursor as a first-class provider in T3 Code using ACP (`agent acp`) over JSON-RPC 2.0 stdio, with robust session lifecycle handling and canonical `ProviderRuntimeEvent` projection. + +--- + +## 1) Exploration Findings (from live ACP probes) + +### 1.1 Core invocation and transport + +1. Binary is `agent` on PATH (`2026.02.27-e7d2ef6` observed). +2. ACP server command is `agent acp`. +3. Transport is newline-delimited JSON-RPC 2.0 over stdio. +4. Messages: + - client -> server: requests and responses to server-initiated requests + - server -> client: responses, notifications (`session/update`), and server requests (`session/request_permission`) + +### 1.2 Handshake and session calls observed + +1. `initialize` returns: + - `protocolVersion` + - `agentCapabilities` (`loadSession`, `mcpCapabilities`, `promptCapabilities`) + - `authMethods` (includes `cursor_login`) +2. `authenticate { methodId: "cursor_login" }` returns `{}` when logged in. +3. `session/new` returns: + - `sessionId` + - `modes` (`agent`, `plan`, `ask`) +4. `session/load` works and requires `sessionId`, `cwd`, `mcpServers`. +5. `session/prompt` returns terminal response `{ stopReason: "end_turn" | "cancelled" }`. + +Important sequence note: +1. ACP currently allows `session/new` even without explicit `initialize`/`authenticate` when local auth already exists. +2. For adapter consistency and forward compatibility, we should still send `initialize` and `authenticate` during startup. + +### 1.3 `session/update` event families observed + +Observed `params.update.sessionUpdate` values: + +1. `available_commands_update` +2. `agent_thought_chunk` +3. `agent_message_chunk` +4. `tool_call` +5. `tool_call_update` + +Observed payload behavior: + +1. `agent_*_chunk` provides `content: { type: "text", text: string }`. +2. `tool_call` may be emitted multiple times for same `toolCallId`: + - initial generic form (`title: "Terminal"`, `rawInput: {}`) + - enriched form (`title: "\`pwd\`"`, `rawInput: { command: "pwd" }`) +3. `tool_call_update` statuses observed: + - `in_progress` + - `completed` +4. `tool_call_update` on completion may include `rawOutput`: + - terminal: `{ exitCode, stdout, stderr }` + - search/find: `{ totalFiles, truncated }` + +### 1.4 Permission flow observed + +1. ACP server sends `session/request_permission` (JSON-RPC request with `id`). +2. Request shape includes: + - `params.sessionId` + - `params.toolCall` + - `params.options` (`allow-once`, `allow-always`, `reject-once`) +3. Client must respond on same `id` with: + - `{ outcome: { outcome: "selected", optionId: "" } }` +4. Reject path still results in tool lifecycle completion events (`tool_call_update status: completed`), typically without `rawOutput`. + +### 1.5 Error and capability quirks + +1. `session/cancel` currently returns: + - JSON-RPC error `-32601` Method not found +2. Error shape examples: + - unknown auth method: `-32602` + - `session/load` missing/invalid params: `-32602` + - `session/prompt` unknown session: `-32603` with details +3. Parallel prompts on same session are effectively single-flight: + - second prompt can cause first to complete with `stopReason: "cancelled"`. +4. `session/new` accepts a `model` field (no explicit echo in response). + +Probe artifacts: +1. `.tmp/acp-probe/*/transcript.ndjson` +2. `.tmp/acp-probe/*/summary.json` +3. `scripts/cursor-acp-probe.mjs` + +--- + +## 2) Integration Constraints for T3 + +1. T3 adapter contract still requires: + - `startSession`, `sendTurn`, `interruptTurn`, `respondToRequest`, `readThread`, `rollbackThread`, `stopSession`, `listSessions`, `hasSession`, `stopAll`, `streamEvents`. +2. Orchestration consumes canonical `ProviderRuntimeEvent` only. +3. `ProviderCommandReactor` provider precedence fix remains required (respect explicit provider on turn start). +4. ACP now supports external permission decisions, so Cursor can participate in T3 approval UX via adapter-managed request/response plumbing. + +--- + +## 3) Proposed Architecture + +### 3.1 New server components + +1. `apps/server/src/provider/Services/CursorAdapter.ts` (service contract/tag + ACP event schemas). +2. `apps/server/src/provider/Layers/CursorAdapter.ts` (single implementation unit; owns ACP process lifecycle, JSON-RPC routing, runtime projection). +3. No manager indirection; keep logic in layer implementation. + +### 3.2 Session model + +1. One long-lived ACP child process per T3 Cursor provider session. +2. Track: + - `providerSessionId` (T3 synthetic ID) + - `acpSessionId` (from `session/new` or restored via `session/load`) + - `cwd`, `model`, in-flight turn state + - pending permission requests by JSON-RPC request id +3. Resume support: + - persist `acpSessionId` in provider resume metadata and call `session/load` on reattach. + +### 3.3 Command strategy + +1. `startSession`: + - spawn `agent acp` + - `initialize` + - `authenticate(cursor_login)` (best-effort, typed failure handling) + - `session/new` or `session/load` +2. `sendTurn`: + - send `session/prompt { sessionId, prompt: [...] }` + - consume streaming `session/update` notifications until terminal prompt response +3. `interruptTurn`: + - no native `session/cancel` today; implement fallback: + - terminate ACP process + restart + `session/load` for subsequent turns + - mark in-flight turn as interrupted/failed in canonical events +4. `respondToRequest`: + - map T3 approval decision -> ACP `optionId` + - reply to exact JSON-RPC request id from `session/request_permission` + +### 3.4 Effect-first implementation style (required) + +1. Keep logic inside `CursorAdapterLive`. +2. Use Effect primitives: + - `Queue` + `Stream.fromQueue` for event fan-out + - `Ref` / `Ref.Synchronized` for session/process/request state + - scoped fibers for stdout/stderr read loops +3. Typed JSON decode at boundary: + - request/response envelopes + - `session/update` union schema + - permission-request schema +4. Keep adapter errors in typed error algebra with explicit mapping at process/protocol boundaries. + +--- + +## 4) Canonical Event Mapping Plan (ACP -> ProviderRuntimeEvent) + +1. `session/update: agent_message_chunk` + - emit `message.delta` for assistant stream +2. prompt terminal response (`session/prompt` result `stopReason: end_turn`) + - emit `message.completed` + `turn.completed` +3. `session/update: agent_thought_chunk` + - initial mapping: emit thinking activity (or ignore if we keep current canonical surface minimal) +4. `session/update: tool_call` + - first-seen `toolCallId` emits `tool.started` + - subsequent `tool_call` for same ID treated as metadata update (no duplicate started event) +5. `session/update: tool_call_update` + - `in_progress`: optional progress activity + - `completed`: emit `tool.completed` with summarized `rawOutput` when present +6. `session/request_permission` + - emit `approval.requested` with mapped options + - when client decision sent, emit `approval.resolved` +7. protocol/process error + - emit `runtime.error` + - fail active turn/session as appropriate + +Synthetic IDs: +1. `turnId`: T3-generated UUID per `sendTurn`. +2. `itemId`: + - assistant stream: `${turnId}:assistant` + - tools: `${turnId}:${toolCallId}` + +--- + +## 5) Approval, Resume, and Rollback Behavior + +### 5.1 Approvals + +1. Cursor ACP permission requests are externally controllable; implement full `respondToRequest` path in v1. +2. Decision mapping: + - allow once -> `allow-once` + - allow always -> `allow-always` + - reject -> `reject-once` + +### 5.2 Resume + +1. `session/load` is available and should be first-class for adapter restart/reconnect. +2. Must send required params: `sessionId`, `cwd`, `mcpServers`. + +### 5.3 Rollback / thread read + +1. ACP currently has no observed rollback API. +2. Plan for v1: + - `readThread`: adapter-maintained snapshot projection + - `rollbackThread`: explicit unsupported error +3. Product guard: + - disable checkpoint revert for Cursor threads in UI until rollback exists. + +--- + +## 6) Required Contract and Runtime Changes + +### 6.1 Contracts + +1. Add `cursor` to `ProviderKind`. +2. Add Cursor provider start options (`providerOptions.cursor`), ACP-oriented: + - optional `binaryPath` + - optional auth/mode knobs if needed later +3. Extend model options for Cursor list and traits mapping. +4. Add schemas for ACP-native event union in Cursor adapter service file. + +### 6.2 Server orchestration and registry + +1. Register `CursorAdapter` in provider registry and server layer wiring. +2. Update provider-kind persistence decoding for `cursor`. +3. Fix `ProviderCommandReactor` precedence to honor explicit provider in turn-start command. + +### 6.3 Web + +1. Cursor in provider picker and model picker (already partially done). +2. Trait controls map to concrete Cursor model identifiers. +3. Surface unsupported rollback behavior in UX. + +--- + +## 7) Implementation Phases + +### Phase A: ACP process and protocol skeleton + +1. Implement ACP process lifecycle in `CursorAdapterLive`. +2. Implement JSON-RPC request/response multiplexer. +3. Implement `initialize`/`authenticate`/`session/new|load` flow. +4. Wire `streamEvents` from ACP notifications. + +### Phase B: Runtime projection and approvals + +1. Map `session/update` variants to canonical runtime events. +2. Implement permission-request bridging to `respondToRequest`. +3. Implement dedupe for repeated `tool_call` on same `toolCallId`. + +### Phase C: Turn control and interruption + +1. Implement single in-flight prompt protection per session. +2. Implement interruption fallback (process restart + reload) because `session/cancel` unavailable. +3. Ensure clean state recovery on ACP process crash. + +### Phase D: Orchestration + UX polish + +1. Provider routing precedence fix. +2. Cursor-specific UX notes for unsupported rollback. +3. End-to-end smoke and event log validation. + +--- + +## 8) Test Plan + +Follow project rule: backend external-service integrations tested via layered fakes, not by mocking core business logic. + +### 8.1 Unit tests (`CursorAdapter`) + +1. JSON-RPC envelope parsing: + - response matching by id + - server request handling (`session/request_permission`) + - notification decode (`session/update`) +2. Event projection: + - `agent_message_chunk` / `agent_thought_chunk` + - `tool_call` + `tool_call_update` dedupe/lifecycle + - permission request -> approval events +3. Error mapping: + - unknown session + - method-not-found (`session/cancel`) + - invalid params + +### 8.2 Provider service/routing tests + +1. Registry resolves `cursor`. +2. Session directory persistence reads/writes `cursor`. +3. ProviderService fan-out ordering with Cursor ACP events. + +### 8.3 Orchestration tests + +1. `thread.turn.start` with `provider: cursor` routes to Cursor adapter. +2. approval response command maps to ACP permission response. +3. checkpoint revert on Cursor thread returns controlled unsupported failure. + +### 8.4 Optional live smoke + +1. Env-gated ACP smoke: + - start session + - run prompt + - observe deltas + completion + - exercise permission request path with one tool call + +--- + +## 9) Operational Notes + +1. Keep one in-flight turn per ACP session. +2. Keep per-session ACP process logs/NDJSON artifacts for debugging. +3. Treat `session/cancel` as unsupported until Cursor ships it; avoid relying on it. +4. Preserve resume metadata (`acpSessionId`) for crash recovery. + +--- + +## 10) Open Questions + +1. Should we call `authenticate` always, or only after auth-required errors? +2. Should model selection be passed at `session/new` only, or can/should we support model switching mid-session if ACP adds API? +3. For interruption UX, do we expose “hard interrupt” semantics (process restart) explicitly? + +--- + +## 11) Delivery Checklist + +1. Plan/documentation switched from headless `agent -p` to ACP `agent acp`. +2. Contracts updated (`ProviderKind`, Cursor options, model/trait mapping). +3. Cursor ACP adapter layer implemented and registered. +4. Provider precedence fixed in orchestration router. +5. Approval response path wired through ACP permission requests. +6. Tests added for protocol decode, projection, approval flow, and routing. +7. Lint + tests green. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c5eb125aba..152f24437e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -25,7 +25,10 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; -import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; +import { + TextGeneration, + type TextGenerationShape, +} from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -93,7 +96,11 @@ export function gitRefExists(cwd: string, ref: string): boolean { } } -export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { +export function gitShowFileAtRef( + cwd: string, + ref: string, + filePath: string, +): string { return runGit(cwd, ["show", `${ref}:${filePath}`]); } @@ -152,7 +159,8 @@ class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass(operation: string, run: () => Promise) => Effect.tryPromise({ try: run, - catch: (cause) => new OrchestrationHarnessRuntimeError({ operation, cause }), + catch: (cause) => + new OrchestrationHarnessRuntimeError({ operation, cause }), }); export interface OrchestrationIntegrationHarness { @@ -179,14 +187,24 @@ export interface OrchestrationIntegrationHarness { requestId: string, predicate: (row: { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: + | "accept" + | "acceptForSession" + | "decline" + | "cancel" + | null; readonly resolvedAt: string | null; }) => boolean, timeoutMs?: number, ) => Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: + | "accept" + | "acceptForSession" + | "decline" + | "cancel" + | null; readonly resolvedAt: string | null; }, never @@ -205,7 +223,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; readonly realCodex?: boolean; } @@ -226,11 +244,15 @@ export const makeOrchestrationIntegrationHarness = ( getByProvider: (resolvedProvider) => resolvedProvider === adapterHarness.provider ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + : 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 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"); @@ -255,7 +277,9 @@ export const makeOrchestrationIntegrationHarness = ( getByProvider: (resolvedProvider) => resolvedProvider === "codex" ? Effect.succeed(codexAdapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + : Effect.fail( + new ProviderUnsupportedError({ provider: resolvedProvider }), + ), listProviders: () => Effect.succeed(["codex"] as const), } as typeof ProviderAdapterRegistry.Service; }), @@ -316,20 +340,25 @@ export const makeOrchestrationIntegrationHarness = ( ); const runtime = ManagedRuntime.make(layer); - const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => - runtime.runPromise(Effect.service(OrchestrationEngineService)), + 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)), + 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)), + 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)), + 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)), + const checkpointStore = yield* tryRuntimePromise( + "load CheckpointStore service", + () => runtime.runPromise(Effect.service(CheckpointStore)), ).pipe(Effect.orDie); const checkpointRepository = yield* tryRuntimePromise( "load ProjectionCheckpointRepository service", @@ -337,19 +366,25 @@ export const makeOrchestrationIntegrationHarness = ( ).pipe(Effect.orDie); const pendingApprovalRepository = yield* tryRuntimePromise( "load ProjectionPendingApprovalRepository service", - () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), + () => + runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), ).pipe(Effect.orDie); - const runtimeReceiptBus = yield* tryRuntimePromise("load RuntimeReceiptBus service", () => - runtime.runPromise(Effect.service(RuntimeReceiptBus)), + const runtimeReceiptBus = yield* tryRuntimePromise( + "load RuntimeReceiptBus service", + () => runtime.runPromise(Effect.service(RuntimeReceiptBus)), ).pipe(Effect.orDie); const scope = yield* Scope.make("sequential"); yield* tryRuntimePromise("start OrchestrationReactor", () => runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), ).pipe(Effect.orDie); - const receiptHistory = yield* Ref.make>([]); + const receiptHistory = yield* Ref.make< + ReadonlyArray + >([]); yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => - Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), + Ref.update(receiptHistory, (history) => [...history, receipt]).pipe( + Effect.asVoid, + ), ).pipe(Effect.forkIn(scope)); yield* sleep(10); @@ -363,64 +398,76 @@ export const makeOrchestrationIntegrationHarness = ( .getSnapshot() .pipe( Effect.map( - (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, + (snapshot) => + snapshot.threads.find((thread) => thread.id === threadId) ?? + null, ), ), - (thread): thread is OrchestrationThread => thread !== null && predicate(thread), + (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", - timeoutMs, - ); + 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 + >; function waitForReceipt( predicate: (receipt: OrchestrationRuntimeReceipt) => boolean, @@ -440,7 +487,8 @@ export const makeOrchestrationIntegrationHarness = ( return waitFor( readMatchingReceipt, - (receipt): receipt is OrchestrationRuntimeReceipt => receipt !== undefined, + (receipt): receipt is OrchestrationRuntimeReceipt => + receipt !== undefined, "runtime receipt", timeoutMs, ); @@ -454,8 +502,12 @@ export const makeOrchestrationIntegrationHarness = ( disposed = true; const shutdown = Effect.gen(function* () { - const closeScopeExit = yield* Effect.exit(Scope.close(scope, Exit.void)); - const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); + const closeScopeExit = yield* Effect.exit( + Scope.close(scope, Exit.void), + ); + const disposeRuntimeExit = yield* Effect.exit( + Effect.promise(() => runtime.dispose()), + ); const failureCause = Exit.isFailure(closeScopeExit) ? closeScopeExit.cause @@ -481,7 +533,7 @@ export const makeOrchestrationIntegrationHarness = ( rootDir, workspaceDir, dbPath, - adapterHarness, + adapterHarness: adapterHarness as TestProviderAdapterHarness, engine, snapshotQuery, providerService, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 017c59e2c8..1ec4557627 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -35,7 +35,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -60,7 +60,9 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupted" | "cancelled" { +function normalizeTurnState( + value: unknown, +): "completed" | "failed" | "interrupted" | "cancelled" { if ( value === "completed" || value === "failed" || @@ -84,7 +86,9 @@ function mapRequestType( return "unknown"; } -function mapItemType(toolKind: unknown): "command_execution" | "file_change" | "unknown" { +function mapItemType( + toolKind: unknown, +): "command_execution" | "file_change" | "unknown" { if (toolKind === "command") { return "command_execution"; } @@ -94,7 +98,9 @@ function mapItemType(toolKind: unknown): "command_execution" | "file_change" | " return "unknown"; } -function normalizeFixtureEvent(rawEvent: Record): ProviderRuntimeEvent { +function normalizeFixtureEvent( + rawEvent: Record, +): ProviderRuntimeEvent { const type = typeof rawEvent.type === "string" ? rawEvent.type : ""; switch (type) { case "turn.started": @@ -128,7 +134,9 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti type: "item.completed", payload: { itemType: "assistant_message", - ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + ...(typeof rawEvent.detail === "string" + ? { detail: rawEvent.detail } + : {}), }, } as ProviderRuntimeEvent; case "tool.started": @@ -137,8 +145,12 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti type: "item.started", payload: { itemType: mapItemType(rawEvent.toolKind), - ...(typeof rawEvent.title === "string" ? { title: rawEvent.title } : {}), - ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + ...(typeof rawEvent.title === "string" + ? { title: rawEvent.title } + : {}), + ...(typeof rawEvent.detail === "string" + ? { detail: rawEvent.detail } + : {}), }, } as ProviderRuntimeEvent; case "tool.completed": @@ -148,8 +160,12 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti payload: { itemType: mapItemType(rawEvent.toolKind), status: "completed", - ...(typeof rawEvent.title === "string" ? { title: rawEvent.title } : {}), - ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + ...(typeof rawEvent.title === "string" + ? { title: rawEvent.title } + : {}), + ...(typeof rawEvent.detail === "string" + ? { detail: rawEvent.detail } + : {}), }, } as ProviderRuntimeEvent; case "approval.requested": @@ -158,7 +174,9 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti type: "request.opened", payload: { requestType: mapRequestType(rawEvent.requestKind), - ...(typeof rawEvent.detail === "string" ? { detail: rawEvent.detail } : {}), + ...(typeof rawEvent.detail === "string" + ? { detail: rawEvent.detail } + : {}), }, } as ProviderRuntimeEvent; case "approval.resolved": @@ -167,7 +185,9 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti type: "request.resolved", payload: { requestType: mapRequestType(rawEvent.requestKind), - ...(typeof rawEvent.decision === "string" ? { decision: rawEvent.decision } : {}), + ...(typeof rawEvent.decision === "string" + ? { decision: rawEvent.decision } + : {}), }, } as ProviderRuntimeEvent; default: @@ -177,7 +197,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -187,7 +207,9 @@ export interface TestProviderAdapterHarness { ) => Effect.Effect; readonly getStartCount: () => number; readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray; - readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray; + readonly getInterruptCalls: ( + threadId: ThreadId, + ) => ReadonlyArray; readonly listActiveSessionIds: () => ReadonlyArray; readonly getApprovalResponses: (threadId: ThreadId) => ReadonlyArray<{ readonly threadId: ThreadId; @@ -197,7 +219,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; } function nowIso(): string { @@ -205,7 +227,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -215,20 +237,25 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); } -export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => +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 interruptCallsBySession = new Map< + ThreadId, + Array + >(); const approvalResponsesBySession = new Map< ThreadId, Array<{ @@ -238,48 +265,55 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }> >(); - const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event); + + const startSession: ProviderAdapterShape["startSession"] = + (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== provider) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: `Expected provider '${provider}' but received '${input.provider}'.`, + }); + } - const startSession: ProviderAdapterShape["startSession"] = (input) => - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== provider) { - return yield* new ProviderAdapterValidationError({ + sessionCount += 1; + const threadId = input.threadId; + const createdAt = nowIso(); + + const session: ProviderSession = { provider, - operation: "startSession", - issue: `Expected provider '${provider}' but received '${input.provider}'.`, - }); - } + status: "ready", + runtimeMode: input.runtimeMode, + threadId, + cwd: input.cwd, + resumeCursor: input.resumeCursor ?? { + threadId: String(threadId), + seed: sessionCount, + }, + createdAt, + updatedAt: createdAt, + }; - sessionCount += 1; - const threadId = input.threadId; - const createdAt = nowIso(); - - const session: ProviderSession = { - provider, - status: "ready", - runtimeMode: input.runtimeMode, - threadId, - cwd: input.cwd, - resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, - createdAt, - updatedAt: createdAt, - }; + sessions.set(threadId, { + session, + snapshot: { + threadId, + turns: [], + }, + turnCount: 0, + queuedResponses: queuedResponsesForNextSession.splice(0), + rollbackCalls: [], + }); - sessions.set(threadId, { - session, - snapshot: { - threadId, - turns: [], - }, - turnCount: 0, - queuedResponses: queuedResponsesForNextSession.splice(0), - rollbackCalls: [], + return session; }); - return session; - }); - - const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + const sendTurn: ProviderAdapterShape["sendTurn"] = ( + input, + ) => Effect.gen(function* () { const state = sessions.get(input.threadId); if (!state) { @@ -317,7 +351,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const runtimeEvent = normalizeFixtureEvent(rawEvent); const runtimeType = (runtimeEvent as { type: string }).type; if (runtimeType === "content.delta") { - const payload = runtimeEvent.payload as { delta?: unknown } | undefined; + const payload = runtimeEvent.payload as + | { delta?: unknown } + | undefined; if (typeof payload?.delta === "string") { assistantDeltas.push(payload.delta); } @@ -336,7 +372,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter } if (response.mutateWorkspace && state.session.cwd) { - yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount }); + yield* response.mutateWorkspace({ + cwd: state.session.cwd!, + turnCount, + }); } const userItem = { @@ -383,88 +422,94 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter } satisfies ProviderTurnStartResult; }); - const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( - 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"] = ( - threadId, - requestId, - decision, - ) => - sessions.has(threadId) - ? Effect.sync(() => { - const existing = approvalResponsesBySession.get(threadId) ?? []; - existing.push({ - threadId, - requestId, - decision, - }); - approvalResponsesBySession.set(threadId, existing); - }) - : missingSessionEffect(provider, threadId); - - const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( - threadId, - _requestId, - _answers, - ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId)); - - const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => - Effect.sync(() => { - sessions.delete(threadId); - }); + const interruptTurn: ProviderAdapterShape["interruptTurn"] = + (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"] = + (threadId, requestId, decision) => + sessions.has(threadId) + ? Effect.sync(() => { + const existing = approvalResponsesBySession.get(threadId) ?? []; + existing.push({ + threadId, + requestId, + decision, + }); + approvalResponsesBySession.set(threadId, existing); + }) + : missingSessionEffect(provider, threadId); + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = + (threadId, _requestId, _answers) => + sessions.has(threadId) + ? Effect.void + : missingSessionEffect(provider, threadId); + + const stopSession: ProviderAdapterShape["stopSession"] = + (threadId) => + Effect.sync(() => { + sessions.delete(threadId); + }); - const listSessions: ProviderAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); + const listSessions: ProviderAdapterShape["listSessions"] = + () => + Effect.sync(() => + Array.from(sessions.values(), (state) => state.session), + ); - const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => - Effect.succeed(sessions.has(threadId)); + const hasSession: ProviderAdapterShape["hasSession"] = + (threadId) => Effect.succeed(sessions.has(threadId)); - const readThread: ProviderAdapterShape["readThread"] = (threadId) => { - const state = sessions.get(threadId); - if (!state) { - return missingSessionEffect(provider, threadId); - } - return Effect.succeed(state.snapshot); - }; + const readThread: ProviderAdapterShape["readThread"] = + (threadId) => { + const state = sessions.get(threadId); + if (!state) { + return missingSessionEffect(provider, threadId); + } + return Effect.succeed(state.snapshot); + }; - const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( - threadId, - numTurns, - ) => { - const state = sessions.get(threadId); - if (!state) { - return missingSessionEffect(provider, threadId); - } - if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider, - operation: "rollbackThread", - issue: "numTurns must be an integer between 0 and current turn count.", - }), - ); - } + const rollbackThread: ProviderAdapterShape["rollbackThread"] = + (threadId, numTurns) => { + const state = sessions.get(threadId); + if (!state) { + return missingSessionEffect(provider, threadId); + } + if ( + !Number.isInteger(numTurns) || + numTurns < 0 || + numTurns > state.snapshot.turns.length + ) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider, + operation: "rollbackThread", + issue: + "numTurns must be an integer between 0 and current turn count.", + }), + ); + } - return Effect.sync(() => { - state.rollbackCalls.push(numTurns); - state.snapshot = { - threadId: state.snapshot.threadId, - turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), - }; - state.turnCount = state.snapshot.turns.length; - return state.snapshot; - }); - }; + return Effect.sync(() => { + state.rollbackCalls.push(numTurns); + state.snapshot = { + threadId: state.snapshot.threadId, + turns: state.snapshot.turns.slice( + 0, + state.snapshot.turns.length - numTurns, + ), + }; + state.turnCount = state.snapshot.turns.length; + return state.snapshot; + }); + }; const stopAll: ProviderAdapterShape["stopAll"] = () => Effect.sync(() => { @@ -521,7 +566,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const getStartCount = (): number => sessionCount; - const getInterruptCalls = (threadId: ThreadId): ReadonlyArray => { + const getInterruptCalls = ( + threadId: ThreadId, + ): ReadonlyArray => { const calls = interruptCallsBySession.get(threadId); if (!calls) { return []; diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 42dcfe34f8..b86a95e240 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -36,7 +36,7 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = "codex"; +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -66,14 +66,20 @@ function waitForSync( return value; } if (Date.now() >= deadline) { - return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); + return yield* Effect.die( + new IntegrationWaitTimeoutError({ description }), + ); } yield* sleep(10); } }); } -function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { +function runtimeBase( + eventId: string, + createdAt: string, + provider: IntegrationProvider = "codex", +) { return { eventId: asEventId(eventId), provider, @@ -154,88 +160,100 @@ const startTurn = (input: { createdAt: nowIso(), }); -it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live( + "runs a single turn end-to-end and persists checkpoint state in sqlite + git", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - const turnResponse: TestTurnResponse = { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), - 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: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }; + const turnResponse: TestTurnResponse = { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), + 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: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }; - yield* harness.adapterHarness!.queueTurnResponseForNextSession(turnResponse); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-single", - messageId: "msg-user-single", - text: "Say hello", - }); - const finalizedReceipt = yield* harness.waitForReceipt( - (receipt): receipt is CheckpointDiffFinalizedReceipt => - receipt.type === "checkpoint.diff.finalized" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); - if (finalizedReceipt.type !== "checkpoint.diff.finalized") { - throw new Error("Expected checkpoint.diff.finalized receipt."); - } - assert.equal(finalizedReceipt.status, "ready"); - yield* harness.waitForReceipt( - (receipt): receipt is TurnProcessingQuiescedReceipt => - receipt.type === "turn.processing.quiesced" && - receipt.threadId === THREAD_ID && - receipt.checkpointTurnCount === 1, - ); + yield* harness.adapterHarness!.queueTurnResponseForNextSession( + turnResponse, + ); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-single", + messageId: "msg-user-single", + text: "Say hello", + }); + const finalizedReceipt = yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); + if (finalizedReceipt.type !== "checkpoint.diff.finalized") { + throw new Error("Expected checkpoint.diff.finalized receipt."); + } + assert.equal(finalizedReceipt.status, "ready"); + yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.status === "ready" && - entry.messages.some( - (message) => message.role === "assistant" && message.streaming === false, - ) && - entry.checkpoints.length === 1, - ); - assert.equal(thread.checkpoints[0]?.status, "ready"); - assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.messages.some( + (message) => + message.role === "assistant" && message.streaming === false, + ) && + entry.checkpoints.length === 1, + ); + assert.equal(thread.checkpoints[0]?.status, "ready"); + assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.equal(checkpointRows.length, 1); - assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); - assert.equal(checkpointRows[0]?.status, "ready"); - assert.deepEqual(checkpointRows[0]?.files, []); - - const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); - const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); - assert.equal(gitRefExists(harness.workspaceDir, ref0), true); - assert.equal(gitRefExists(harness.workspaceDir, ref1), true); - assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); - assert.equal(gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), "v1\n"); - }), - ), + const checkpointRows = + yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.equal(checkpointRows.length, 1); + assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); + assert.equal(checkpointRows[0]?.status, "ready"); + assert.deepEqual(checkpointRows[0]?.files, []); + + const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); + const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); + assert.equal(gitRefExists(harness.workspaceDir, ref0), true); + assert.equal(gitRefExists(harness.workspaceDir, ref1), true); + assert.equal( + gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), + "v1\n", + ); + assert.equal( + gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), + "v1\n", + ); + }), + ), ); it.live.skipIf(!process.env.CODEX_BINARY_PATH)( @@ -290,7 +308,8 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( entry.session?.status === "ready" && entry.session.providerName === "codex" && entry.messages.some( - (message) => message.role === "assistant" && message.streaming === false, + (message) => + message.role === "assistant" && message.streaming === false, ), 180_000, ); @@ -318,7 +337,8 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( entry.session.providerName === "codex" && entry.session.runtimeMode === "approval-required" && entry.messages.some( - (message) => message.role === "assistant" && message.text.includes("BETA"), + (message) => + message.role === "assistant" && message.text.includes("BETA"), ), 180_000, ); @@ -394,7 +414,9 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => yield* harness.waitForThread( THREAD_ID, - (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", + (entry) => + entry.checkpoints.length === 1 && + entry.session?.threadId === "thread-1", ); yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { @@ -454,7 +476,9 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => (entry) => entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && - entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), + entry.checkpoints.some( + (checkpoint) => checkpoint.checkpointTurnCount === 2, + ), ); const secondCheckpoint = secondTurnThread.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === 2, @@ -464,9 +488,11 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => true, ); - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); + const checkpointRows = yield* harness.checkpointRepository.listByThreadId( + { + threadId: THREAD_ID, + }, + ); assert.deepEqual( checkpointRows.map((row) => row.checkpointTurnCount), [1, 2], @@ -508,342 +534,393 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => ), ); -it.live("tracks approval requests and resolves pending approvals on user response", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live( + "tracks approval requests and resolves pending approvals on user response", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "approval.requested", - ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - requestId: APPROVAL_REQUEST_ID, - requestKind: "command", - detail: "Approve command execution", - }, - { - type: "turn.completed", - ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - }); + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve command execution", + }, + { + type: "turn.completed", + ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-approval", - messageId: "msg-user-approval", - text: "Run command needing approval", - }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-approval", + messageId: "msg-user-approval", + text: "Run command needing approval", + }); - const thread = yield* harness.waitForThread(THREAD_ID, (entry) => - entry.activities.some((activity) => activity.kind === "approval.requested"), - ); - assert.equal( - thread.activities.some((activity) => activity.kind === "approval.requested"), - true, - ); + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some( + (activity) => activity.kind === "approval.requested", + ), + ); + assert.equal( + thread.activities.some( + (activity) => activity.kind === "approval.requested", + ), + true, + ); - const pendingRow = yield* harness.waitForPendingApproval( - "req-approval-1", - (row) => row.status === "pending" && row.decision === null, - ); - assert.equal(pendingRow.status, "pending"); - - yield* harness.engine.dispatch({ - type: "thread.approval.respond", - commandId: CommandId.makeUnsafe("cmd-approval-respond"), - threadId: THREAD_ID, - requestId: APPROVAL_REQUEST_ID, - decision: "accept", - createdAt: nowIso(), - }); + const pendingRow = yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "pending" && row.decision === null, + ); + assert.equal(pendingRow.status, "pending"); - const resolvedRow = yield* harness.waitForPendingApproval( - "req-approval-1", - (row) => row.status === "resolved" && row.decision === "accept", - ); - assert.equal(resolvedRow.status, "resolved"); - assert.equal(resolvedRow.decision, "accept"); + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); - const approvalResponses = yield* waitForSync( - () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), - (responses) => responses.length === 1, - "provider approval response", - ); - assert.equal(approvalResponses.length, 1); - assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); - assert.equal(approvalResponses[0]?.decision, "accept"); - }), - ), + const resolvedRow = yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + assert.equal(resolvedRow.status, "resolved"); + assert.equal(resolvedRow.decision, "accept"); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "provider approval response", + ); + assert.equal(approvalResponses.length, 1); + assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + ), ); -it.live("records failed turn runtime state and checkpoint status as error", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live( + "records failed turn runtime state and checkpoint status as error", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "content.delta", - ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - streamKind: "assistant_text", - delta: "Partial output before failure.\n", + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, }, - }, - { - type: "runtime.error", - ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - message: "Sandbox command failed.", + { + type: "content.delta", + ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + streamKind: "assistant_text", + delta: "Partial output before failure.\n", + }, }, - }, - { - type: "turn.completed", - ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - state: "failed", - errorMessage: "Sandbox command failed.", + { + type: "runtime.error", + ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + message: "Sandbox command failed.", + }, }, - }, - ], - }); + { + type: "turn.completed", + ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + state: "failed", + errorMessage: "Sandbox command failed.", + }, + }, + ], + }); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-failure", - messageId: "msg-user-failure", - text: "Run risky command", - }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-failure", + messageId: "msg-user-failure", + text: "Run risky command", + }); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.status === "error" && - entry.session?.lastError === "Sandbox command failed." && - entry.activities.some((activity) => activity.kind === "runtime.error") && - entry.checkpoints.length === 1, - ); - assert.equal(thread.session?.status, "error"); - assert.equal(thread.checkpoints[0]?.status, "error"); + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "error" && + entry.session?.lastError === "Sandbox command failed." && + entry.activities.some( + (activity) => activity.kind === "runtime.error", + ) && + entry.checkpoints.length === 1, + ); + assert.equal(thread.session?.status, "error"); + assert.equal(thread.checkpoints[0]?.status, "error"); - const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ - threadId: THREAD_ID, - checkpointTurnCount: 1, - }); - assert.equal(Option.isSome(checkpointRow), true); - if (Option.isSome(checkpointRow)) { - assert.equal(checkpointRow.value.status, "error"); - } - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), - true, - ); - }), - ), + const checkpointRow = + yield* harness.checkpointRepository.getByThreadAndTurnCount({ + threadId: THREAD_ID, + checkpointTurnCount: 1, + }); + assert.equal(Option.isSome(checkpointRow), true); + if (Option.isSome(checkpointRow)) { + assert.equal(checkpointRow.value.status, "error"); + } + assert.equal( + gitRefExists( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 1), + ), + true, + ); + }), + ), ); -it.live("reverts to an earlier checkpoint and trims checkpoint projections + git refs", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live( + "reverts to an earlier checkpoint and trims checkpoint projections + git refs", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "message.delta", - ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), - 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: 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-revert-1", - messageId: "msg-user-revert-1", - text: "First edit", - }); + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase( + "evt-revert-1-tool-started", + "2026-02-24T10:05:00.025Z", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase( + "evt-revert-1-tool-completed", + "2026-02-24T10:05:00.035Z", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), + 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: 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-revert-1", + messageId: "msg-user-revert-1", + text: "First edit", + }); - yield* harness.waitForThread( - THREAD_ID, - (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, - ); + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.threadId === "thread-1" && + entry.checkpoints.length === 1, + ); - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "message.delta", - ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), - 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: 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-revert-2", - messageId: "msg-user-revert-2", - text: "Second edit", - }); + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase( + "evt-revert-3-tool-started", + "2026-02-24T10:05:01.025Z", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase( + "evt-revert-3-tool-completed", + "2026-02-24T10:05:01.035Z", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), + 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: 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-revert-2", + messageId: "msg-user-revert-2", + text: "Second edit", + }); - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-2" && - entry.checkpoints.length === 2 && - entry.activities.some((activity) => activity.turnId === "turn-2"), - 8000, - ); + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.activities.some((activity) => activity.turnId === "turn-2"), + 8000, + ); - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), - threadId: THREAD_ID, - turnCount: 1, - createdAt: nowIso(), - }); + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); - yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); - 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.deepEqual( - revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), - [ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ], - ); - assert.equal( - revertedThread.activities.some((activity) => activity.turnId === "turn-2"), - false, - ); - assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", - ), - true, - ); - assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", - ), - true, - ); - assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), - false, - ); - assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + yield* harness.waitForDomainEvent( + (event) => event.type === "thread.reverted", + ); + 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.deepEqual( + revertedThread.messages.map((message) => ({ + role: message.role, + text: message.text, + })), + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], + ); + assert.equal( + revertedThread.activities.some( + (activity) => activity.turnId === "turn-2", + ), + false, + ); + assert.equal( + revertedThread.activities.some( + (activity) => + activity.turnId === "turn-1" && activity.kind === "tool.started", + ), + true, + ); + assert.equal( + revertedThread.activities.some( + (activity) => + activity.turnId === "turn-1" && + activity.kind === "tool.completed", + ), + true, + ); + assert.equal( + fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), + "v2\n", + ); + assert.equal( + gitRefExists( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 2), + ), + false, + ); + assert.deepEqual( + harness.adapterHarness!.getRollbackCalls(THREAD_ID), + [1], + ); - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.equal(checkpointRows.length, 1); - }), - ), + const checkpointRows = + yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.equal(checkpointRows.length, 1); + }), + ), ); it.live( @@ -875,10 +952,540 @@ it.live( assert.equal(failureActivity !== undefined, true); assert.equal( String( - (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, + ( + failureActivity?.payload as + | { readonly detail?: string } + | undefined + )?.detail, ).includes("No active provider session"), true, ); }), ), ); + +it.live( + "starts a claudeCode session on first turn when provider is requested", + () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-start-1", + "2026-02-24T10:10:00.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-start-2", + "2026-02-24T10:10:00.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-start-3", + "2026-02-24T10:10:00.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => + message.role === "assistant" && + message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live( + "recovers claudeCode sessions after provider stopAll using persisted resume state", + () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-recover-1", + "2026-02-24T10:11:00.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-recover-2", + "2026-02-24T10:11:00.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-recover-3", + "2026-02-24T10:11:00.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && + entry.session?.threadId === "thread-1", + ); + + yield* harness.providerService.stopAll(); + yield* waitForSync( + () => harness.adapterHarness!.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-recover-4", + "2026-02-24T10:11:01.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-recover-5", + "2026-02-24T10:11:01.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-recover-6", + "2026-02-24T10:11:01.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + }); + yield* waitForSync( + () => harness.adapterHarness!.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => + message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some( + (activity) => activity.kind === "provider.turn.start.failed", + ), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-approval-1", + "2026-02-24T10:12:00.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase( + "evt-claude-approval-2", + "2026-02-24T10:12:00.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-approval-3", + "2026-02-24T10:12:00.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some( + (activity) => activity.kind === "approval.requested", + ), + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness!.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-interrupt-1", + "2026-02-24T10:13:00.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-interrupt-2", + "2026-02-24T10:13:00.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-interrupt-3", + "2026-02-24T10:13:00.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1", + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent( + (event) => event.type === "thread.turn-interrupt-requested", + ); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness!.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live( + "reverts claudeCode turns and rolls back provider conversation state", + () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-revert-1", + "2026-02-24T10:14:00.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-revert-2", + "2026-02-24T10:14:00.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-revert-3", + "2026-02-24T10:14:00.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && + entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase( + "evt-claude-revert-4", + "2026-02-24T10:14:01.000Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase( + "evt-claude-revert-5", + "2026-02-24T10:14:01.050Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase( + "evt-claude-revert-6", + "2026-02-24T10:14:01.100Z", + "claudeCode", + ), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && + entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal( + gitRefExists( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 1), + ), + true, + ); + assert.equal( + gitRefExists( + harness.workspaceDir, + checkpointRefForThreadTurn(THREAD_ID, 2), + ), + false, + ); + assert.deepEqual( + harness.adapterHarness!.getRollbackCalls(THREAD_ID), + [1], + ); + }), + "claudeCode", + ), +); diff --git a/apps/server/package.json b/apps/server/package.json index 546a2c3b68..19b0c20c97 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/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index cea8df0a0b..85b6711115 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -47,7 +47,9 @@ function createSendTurnHarness() { .mockReturnValue(context); const sendRequest = vi .spyOn( - manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, + manager as unknown as { + sendRequest: (...args: unknown[]) => Promise; + }, "sendRequest", ) .mockResolvedValue({ @@ -56,7 +58,10 @@ function createSendTurnHarness() { }, }); const updateSession = vi - .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") + .spyOn( + manager as unknown as { updateSession: (...args: unknown[]) => void }, + "updateSession", + ) .mockImplementation(() => {}); return { manager, context, requireSession, sendRequest, updateSession }; @@ -84,11 +89,16 @@ function createThreadControlHarness() { ) .mockReturnValue(context); const sendRequest = vi.spyOn( - manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, + manager as unknown as { + sendRequest: (...args: unknown[]) => Promise; + }, "sendRequest", ); const updateSession = vi - .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") + .spyOn( + manager as unknown as { updateSession: (...args: unknown[]) => void }, + "updateSession", + ) .mockImplementation(() => {}); return { manager, context, requireSession, sendRequest, updateSession }; @@ -98,6 +108,7 @@ function createPendingUserInputHarness() { const manager = new CodexAppServerManager(); const context = { session: { + sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", @@ -126,10 +137,16 @@ function createPendingUserInputHarness() { ) .mockReturnValue(context); const writeMessage = vi - .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") + .spyOn( + manager as unknown as { writeMessage: (...args: unknown[]) => void }, + "writeMessage", + ) .mockImplementation(() => {}); const emitEvent = vi - .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .spyOn( + manager as unknown as { emitEvent: (...args: unknown[]) => void }, + "emitEvent", + ) .mockImplementation(() => {}); return { manager, context, requireSession, writeMessage, emitEvent }; @@ -153,7 +170,8 @@ describe("classifyCodexStderrLine", () => { }); it("keeps unknown structured errors", () => { - const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; + const line = + "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; expect(classifyCodexStderrLine(line)).toEqual({ message: line, }); @@ -174,7 +192,9 @@ describe("normalizeCodexModelSlug", () => { }); it("prefers codex id when model differs", () => { - expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); + expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe( + "gpt-5.3-codex", + ); }); it("keeps non-aliased models as-is", () => { @@ -186,13 +206,17 @@ describe("normalizeCodexModelSlug", () => { describe("isRecoverableThreadResumeError", () => { it("matches not-found resume errors", () => { expect( - isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), + isRecoverableThreadResumeError( + new Error("thread/resume failed: thread not found"), + ), ).toBe(true); }); it("ignores non-resume errors", () => { expect( - isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), + isRecoverableThreadResumeError( + new Error("thread/start failed: permission denied"), + ), ).toBe(false); }); @@ -285,7 +309,8 @@ describe("startSession", () => { 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 }> = []; + const events: Array<{ method: string; kind: string; message?: string }> = + []; manager.on("event", (event) => { events.push({ method: event.method, @@ -319,7 +344,8 @@ describe("startSession", () => { it("fails fast with an upgrade message when codex is below the minimum supported version", async () => { const manager = new CodexAppServerManager(); - const events: Array<{ method: string; kind: string; message?: string }> = []; + const events: Array<{ method: string; kind: string; message?: string }> = + []; manager.on("event", (event) => { events.push({ method: event.method, @@ -536,6 +562,36 @@ 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(); @@ -549,14 +605,20 @@ describe("sendTurn", () => { describe("thread checkpoint control", () => { it("reads thread turns from thread/read", async () => { - const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); + const { manager, context, requireSession, sendRequest } = + createThreadControlHarness(); sendRequest.mockResolvedValue({ thread: { id: "thread_1", turns: [ { id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + items: [ + { + type: "userMessage", + content: [{ type: "text", text: "hello" }], + }, + ], }, ], }, @@ -574,7 +636,9 @@ describe("thread checkpoint control", () => { turns: [ { id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + items: [ + { type: "userMessage", content: [{ type: "text", text: "hello" }] }, + ], }, ], }); @@ -587,7 +651,9 @@ describe("thread checkpoint control", () => { turns: [ { id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + items: [ + { type: "userMessage", content: [{ type: "text", text: "hello" }] }, + ], }, ], }); @@ -603,14 +669,17 @@ describe("thread checkpoint control", () => { turns: [ { id: "turn_1", - items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + items: [ + { type: "userMessage", content: [{ type: "text", text: "hello" }] }, + ], }, ], }); }); it("rolls back turns via thread/rollback and resets session running state", async () => { - const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); + const { manager, context, sendRequest, updateSession } = + createThreadControlHarness(); sendRequest.mockResolvedValue({ thread: { id: "thread_1", @@ -748,83 +817,100 @@ describe("respondToUserInput", () => { }); }); -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"); +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(); + 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 } : {}), + 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()}`, - }); + const firstTurn = await manager.sendTurn({ + threadId: firstSession.threadId, + input: `Reply with exactly the word ALPHA ${randomUUID()}`, + }); - expect(firstTurn.threadId).toBe(firstSession.threadId); + 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 }, - ); + 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; + const firstSnapshot = await manager.readThread(firstSession.threadId); + const originalThreadId = firstSnapshot.threadId; + const originalTurnCount = firstSnapshot.turns.length; - manager.stopSession(firstSession.threadId); + 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 } : {}), + 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); + expect(resumedSession.threadId).toBe(originalThreadId); - const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); - expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); - expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); + 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 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); -}); + 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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 09773b71dc..f5bdd245b0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -14,7 +14,15 @@ import { TurnId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { + Effect, + Exit, + Layer, + ManagedRuntime, + PubSub, + Scope, + Stream, +} from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; @@ -44,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -61,13 +69,19 @@ function createProviderServiceHarness( providerName: ProviderSession["provider"] = "codex", ) { const now = new Date().toISOString(); - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeEventPubSub = Effect.runSync( + PubSub.unbounded(), + ); const rollbackConversation = vi.fn( - (_input: { readonly threadId: ThreadId; 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; + Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect< + A, + never + >; const listSessions = () => hasSession ? Effect.succeed([ @@ -89,6 +103,7 @@ function createProviderServiceHarness( respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + stopAll: () => Effect.void, listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation, @@ -96,7 +111,12 @@ function createProviderServiceHarness( }; const emit = (event: LegacyProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); + Effect.runSync( + PubSub.publish( + runtimeEventPubSub, + event as unknown as ProviderRuntimeEvent, + ), + ); }; return { @@ -122,7 +142,9 @@ async function waitForThread( activities: ReadonlyArray<{ kind: string }>; }> => { const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); if (thread && predicate(thread)) { return thread; } @@ -143,7 +165,9 @@ async function waitForEvent( const deadline = Date.now() + timeoutMs; const poll = async () => { const events = await Effect.runPromise( - Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ), ); if (events.some(predicate)) { return events; @@ -235,6 +259,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; + readonly providerName?: "codex" | "claudeCode"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -242,7 +267,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - "codex", + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -261,9 +286,13 @@ describe("CheckpointReactor", () => { ); runtime = ManagedRuntime.make(layer); - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const engine = await runtime.runPromise( + Effect.service(OrchestrationEngineService), + ); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); - const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + const checkpointStore = await runtime.runPromise( + Effect.service(CheckpointStore), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -300,21 +329,30 @@ describe("CheckpointReactor", () => { await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 0, + ), }), ); fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 1, + ), }), ); fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); await runtime.runPromise( checkpointStore.captureCheckpoint({ cwd, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 2, + ), }), ); } @@ -375,17 +413,27 @@ describe("CheckpointReactor", () => { payload: { state: "completed" }, }); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + await waitForEvent( + harness.engine, + (event) => event.type === "thread.turn-diff-completed", + ); const thread = await waitForThread( harness.engine, - (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + gitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ), ).toBe(true); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + gitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + ), ).toBe(true); expect( gitShowFileAtRef( @@ -472,9 +520,80 @@ describe("CheckpointReactor", () => { const thread = await waitForThread( harness.engine, - (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, + (entry) => + entry.latestTurn?.turnId === "turn-main" && + entry.checkpoints.length === 1, + ); + 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 () => { @@ -484,7 +603,9 @@ describe("CheckpointReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), + commandId: CommandId.makeUnsafe( + "cmd-session-set-missing-baseline-diff", + ), threadId: ThreadId.makeUnsafe("thread-1"), session: { threadId: ThreadId.makeUnsafe("thread-1"), @@ -510,17 +631,24 @@ describe("CheckpointReactor", () => { payload: { state: "completed" }, }); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + await waitForEvent( + harness.engine, + (event) => event.type === "thread.turn-diff-completed", + ); const thread = await waitForThread( harness.engine, (entry) => entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + entry.activities.some( + (activity) => activity.kind === "checkpoint.capture.failed", + ), ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + thread.activities.some( + (activity) => activity.kind === "checkpoint.capture.failed", + ), ).toBe(true); }); @@ -599,9 +727,15 @@ describe("CheckpointReactor", () => { payload: { state: "completed" }, }); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + await waitForEvent( + harness.engine, + (event) => event.type === "thread.turn-diff-completed", + ); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + gitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + ), ).toBe(true); expect( gitShowFileAtRef( @@ -648,10 +782,14 @@ describe("CheckpointReactor", () => { await harness.drain(); 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, + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), ); + expect( + thread?.checkpoints.some( + (checkpoint) => checkpoint.checkpointTurnCount === 3, + ), + ).toBe(false); }); it("continues processing runtime events after a single checkpoint runtime failure", async () => { @@ -710,7 +848,10 @@ describe("CheckpointReactor", () => { checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), ); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), + gitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ), ).toBe(true); }); @@ -743,7 +884,10 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 1, + ), status: "ready", files: [], checkpointTurnCount: 1, @@ -757,7 +901,10 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-2"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 2, + ), status: "ready", files: [], checkpointTurnCount: 2, @@ -775,8 +922,14 @@ describe("CheckpointReactor", () => { }), ); - await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); - const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); + await waitForEvent( + harness.engine, + (event) => event.type === "thread.reverted", + ); + const thread = await waitForThread( + harness.engine, + (entry) => entry.checkpoints.length === 1, + ); expect(thread.latestTurn?.turnId).toBe("turn-1"); expect(thread.checkpoints).toHaveLength(1); @@ -786,12 +939,95 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), numTurns: 1, }); - expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe( + "v2\n", + ); expect( - gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), + gitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + ), ).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(); @@ -821,7 +1057,10 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 1, + ), status: "ready", files: [], checkpointTurnCount: 1, @@ -835,7 +1074,10 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-2"), completedAt: createdAt, - checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + checkpointRef: checkpointRefForThreadTurn( + ThreadId.makeUnsafe("thread-1"), + 2, + ), status: "ready", files: [], checkpointTurnCount: 2, @@ -901,12 +1143,16 @@ describe("CheckpointReactor", () => { ); const thread = await waitForThread(harness.engine, (entry) => - entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + entry.activities.some( + (activity) => activity.kind === "checkpoint.revert.failed", + ), ); - expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( - true, - ); + expect( + thread.activities.some( + (activity) => activity.kind === "checkpoint.revert.failed", + ), + ).toBe(true); expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f9..6bf6584f6e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -13,7 +13,15 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { + Effect, + Exit, + Layer, + ManagedRuntime, + PubSub, + Scope, + Stream, +} from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -27,7 +35,10 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; -import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { + TextGeneration, + type TextGenerationShape, +} from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; @@ -85,9 +96,13 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly stateDir?: string }) { const now = new Date().toISOString(); - const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + const stateDir = + input?.stateDir ?? + fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdStateDirs.add(stateDir); - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeEventPubSub = Effect.runSync( + PubSub.unbounded(), + ); let nextSessionIndex = 1; const runtimeSessions: Array = []; const startSession = vi.fn((_: unknown, input: unknown) => { @@ -96,7 +111,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || + input.provider === "claudeCode" || + input.provider === "cursor") ? input.provider : "codex"; const resumeCursor = @@ -124,7 +141,8 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "runtimeMode" in input && - (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") + (input.runtimeMode === "approval-required" || + input.runtimeMode === "full-access") ? input.runtimeMode : "full-access", ...(model !== undefined ? { model } : {}), @@ -143,8 +161,12 @@ describe("ProviderCommandReactor", () => { }), ); const interruptTurn = vi.fn((_: unknown) => Effect.void); - const respondToRequest = vi.fn(() => Effect.void); - const respondToUserInput = vi.fn(() => Effect.void); + const respondToRequest = vi.fn( + () => Effect.void, + ); + const respondToUserInput = vi.fn< + ProviderServiceShape["respondToUserInput"] + >(() => Effect.void); const stopSession = vi.fn((input: unknown) => Effect.sync(() => { const threadId = @@ -154,7 +176,9 @@ describe("ProviderCommandReactor", () => { if (!threadId) { return; } - const index = runtimeSessions.findIndex((session) => session.threadId === threadId); + const index = runtimeSessions.findIndex( + (session) => session.threadId === threadId, + ); if (index >= 0) { runtimeSessions.splice(index, 1); } @@ -180,18 +204,23 @@ describe("ProviderCommandReactor", () => { ), ); - const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const unsupported = () => + Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], - respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], - respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], + respondToRequest: + respondToRequest as ProviderServiceShape["respondToRequest"], + respondToUserInput: + respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], + stopAll: () => Effect.void, listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (provider) => Effect.succeed({ - sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", + sessionModelSwitch: + provider === "cursor" ? "unsupported" : "in-session", }), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -206,17 +235,25 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), + Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape), + ), + Layer.provideMerge( + Layer.succeed(TextGeneration, { + generateBranchName, + } as unknown as TextGenerationShape), ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + const engine = await runtime.runPromise( + Effect.service(OrchestrationEngineService), + ); + const reactor = await runtime.runPromise( + Effect.service(ProviderCommandReactor), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(reactor.drain); @@ -286,7 +323,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); + expect(harness.startSession.mock.calls[0]?.[0]).toEqual( + ThreadId.makeUnsafe("thread-1"), + ); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", model: "gpt-5-codex", @@ -294,7 +333,9 @@ describe("ProviderCommandReactor", () => { }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -389,6 +430,84 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts first turn with requested provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-first"), + role: "user", + text: "hello claude", + attachments: [], + }, + provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + expect(thread?.session?.providerName).toBe("claudeCode"); + expect(thread?.session?.threadId).toBe("thread-1"); + }); + + it("starts first turn with cursor provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-cursor"), + role: "user", + text: "hello cursor", + attachments: [], + }, + provider: "cursor", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "cursor", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + expect(thread?.session?.providerName).toBe("cursor"); + expect(thread?.session?.threadId).toBe("thread-1"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -435,6 +554,123 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("reuses the same cursor session when requested model is unchanged", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.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", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + + it("keeps cursor session/model when model change is unsupported", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "gpt-5.3-codex", + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -442,7 +678,9 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"), + commandId: CommandId.makeUnsafe( + "cmd-runtime-mode-set-initial-full-access", + ), threadId: ThreadId.makeUnsafe("thread-1"), runtimeMode: "full-access", createdAt: now, @@ -517,8 +755,74 @@ describe("ProviderCommandReactor", () => { }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + }); + + it("switches provider by restarting the session when turn request provider changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + provider: "claudeCode", + runtimeMode: "approval-required", + }); + expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty( + "resumeCursor", + ); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.providerName).toBe("claudeCode"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -529,7 +833,9 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.runtime-mode.set", - commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"), + commandId: CommandId.makeUnsafe( + "cmd-runtime-mode-set-initial-full-access-2", + ), threadId: ThreadId.makeUnsafe("thread-1"), runtimeMode: "full-access", createdAt: now, @@ -557,7 +863,8 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); harness.startSession.mockImplementationOnce( - (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, + (_: unknown, __: unknown) => + Effect.fail(new Error("simulated restart failure")) as never, ); await Effect.runPromise( @@ -584,7 +891,9 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("full-access"); }); @@ -719,7 +1028,7 @@ describe("ProviderCommandReactor", () => { harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "codex", + provider: "cursor", method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -734,7 +1043,7 @@ describe("ProviderCommandReactor", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", - providerName: "codex", + providerName: "cursor", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -788,7 +1097,9 @@ describe("ProviderCommandReactor", () => { }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(thread).toBeDefined(); const failureActivity = thread?.activities.find( @@ -804,7 +1115,8 @@ describe("ProviderCommandReactor", () => { activity.kind === "approval.resolved" && typeof activity.payload === "object" && activity.payload !== null && - (activity.payload as Record).requestId === "approval-request-1", + (activity.payload as Record).requestId === + "approval-request-1", ); expect(resolvedActivity).toBeUndefined(); }); @@ -842,7 +1154,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.stopSession.mock.calls.length === 1); const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + 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?.threadId).toBe("thread-1"); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe02188450..790606e850 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,12 +12,24 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; +import { + Cache, + Cause, + Duration, + Effect, + Layer, + Option, + Schema, + Stream, +} from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; +import { + ProviderAdapterRequestError, + ProviderServiceError, +} from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -39,7 +51,9 @@ type ProviderIntentEvent = Extract< } >; -function toNonEmptyProviderInput(value: string | undefined): string | undefined { +function toNonEmptyProviderInput( + value: string | undefined, +): string | undefined { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; } @@ -63,7 +77,9 @@ function mapProviderSessionStatusToOrchestrationStatus( } const turnStartKeyForEvent = (event: ProviderIntentEvent): string => - event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; + event.commandId !== null + ? `command:${event.commandId}` + : `event:${event.eventId}`; const serverCommandId = (tag: string): CommandId => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -72,9 +88,13 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( + `^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`, +); -function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { +function isUnknownPendingApprovalRequestError( + cause: Cause.Cause, +): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { const detail = error.detail.toLowerCase(); @@ -131,7 +151,9 @@ const make = Effect.gen(function* () { const hasHandledTurnStartRecently = (key: string) => Cache.getOption(handledTurnStartKeys, key).pipe( Effect.flatMap((cached) => - Cache.set(handledTurnStartKeys, key, true).pipe(Effect.as(Option.isSome(cached))), + Cache.set(handledTurnStartKeys, key, true).pipe( + Effect.as(Option.isSome(cached)), + ), ), ); @@ -201,13 +223,20 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); if (!thread) { - return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); + return yield* Effect.die( + new Error(`Thread '${threadId}' was not found in read model.`), + ); } const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; - const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; + 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, @@ -217,7 +246,11 @@ const make = Effect.gen(function* () { const resolveActiveSession = (threadId: ThreadId) => providerService .listSessions() - .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId))); + .pipe( + Effect.map((sessions) => + sessions.find((session) => session.threadId === threadId), + ), + ); const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -230,11 +263,15 @@ const make = Effect.gen(function* () { : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(desiredModel ? { model: desiredModel } : {}), - ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(options?.modelOptions !== undefined + ? { modelOptions: options.modelOptions } + : {}), ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input?.resumeCursor !== undefined + ? { resumeCursor: input.resumeCursor } + : {}), runtimeMode: desiredRuntimeMode, }); @@ -257,18 +294,28 @@ const make = Effect.gen(function* () { const existingSessionThreadId = thread.session && thread.session.status !== "stopped" ? thread.id : null; if (existingSessionThreadId) { - const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; + const runtimeModeChanged = + thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionThreadId); + 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"; + : (yield* providerService.getCapabilities(currentProvider)) + .sessionModelSwitch; + const modelChanged = + options?.model !== undefined && options.model !== activeSession?.model; + const shouldRestartForModelChange = + modelChanged && sessionModelSwitch === "restart-session"; - if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + if ( + !runtimeModeChanged && + !providerChanged && + !shouldRestartForModelChange + ) { return existingSessionThreadId; } @@ -276,36 +323,46 @@ const make = Effect.gen(function* () { 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, - }); + 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, + ...(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); return restartedSession.threadId; } const startedSession = yield* startProviderSession( - options?.provider !== undefined ? { provider: options.provider } : undefined, + options?.provider !== undefined + ? { provider: options.provider } + : undefined, ); yield* bindSessionToThread(startedSession); return startedSession.threadId; @@ -332,102 +389,138 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + ...(input.modelOptions !== undefined + ? { modelOptions: input.modelOptions } + : {}), + ...(input.providerOptions !== undefined + ? { providerOptions: input.providerOptions } + : {}), }); 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)), + 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.getCapabilities(activeSession.provider)) + .sessionModelSwitch; + const modelForTurn = + sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; yield* providerService.sendTurn({ threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), - ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), + ...(normalizedAttachments.length > 0 + ? { attachments: normalizedAttachments } + : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), - ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(input.modelOptions !== undefined + ? { modelOptions: input.modelOptions } + : {}), + ...(input.interactionMode !== undefined + ? { interactionMode: input.interactionMode } + : {}), }); }); - const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { - readonly threadId: ThreadId; - readonly branch: string | null; - readonly worktreePath: string | null; - readonly messageId: string; - readonly messageText: string; - readonly attachments?: ReadonlyArray; - }) { - if (!input.branch || !input.worktreePath) { - return; - } - if (!isTemporaryWorktreeBranch(input.branch)) { - return; - } + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced( + function* (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly messageId: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + }) { + if (!input.branch || !input.worktreePath) { + return; + } + if (!isTemporaryWorktreeBranch(input.branch)) { + return; + } - const thread = yield* resolveThread(input.threadId); - if (!thread) { - return; - } + const thread = yield* resolveThread(input.threadId); + if (!thread) { + return; + } - const userMessages = thread.messages.filter((message) => message.role === "user"); - if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { - return; - } + const userMessages = thread.messages.filter( + (message) => message.role === "user", + ); + if ( + userMessages.length !== 1 || + userMessages[0]?.id !== input.messageId + ) { + return; + } - const oldBranch = input.branch; - const cwd = input.worktreePath; - const attachments = input.attachments ?? []; - yield* textGeneration - .generateBranchName({ - cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - }) - .pipe( - Effect.catch((error) => - Effect.logWarning( - "provider command reactor failed to generate worktree branch name; skipping rename", - { threadId: input.threadId, cwd, oldBranch, reason: error.message }, + const oldBranch = input.branch; + const cwd = input.worktreePath; + const attachments = input.attachments ?? []; + yield* textGeneration + .generateBranchName({ + cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + "provider command reactor failed to generate worktree branch name; skipping rename", + { + threadId: input.threadId, + cwd, + oldBranch, + reason: error.message, + }, + ), ), - ), - Effect.flatMap((generated) => { - if (!generated) return Effect.void; - - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); - if (targetBranch === oldBranch) return Effect.void; - - return Effect.flatMap( - git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), - (renamed) => - orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("worktree-branch-rename"), + Effect.flatMap((generated) => { + if (!generated) return Effect.void; + + const targetBranch = buildGeneratedWorktreeBranchName( + generated.branch, + ); + if (targetBranch === oldBranch) return Effect.void; + + return Effect.flatMap( + git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), + (renamed) => + orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }), + ); + }), + Effect.catchCause((cause) => + Effect.logWarning( + "provider command reactor failed to generate or rename worktree branch", + { threadId: input.threadId, - branch: renamed.branch, - worktreePath: cwd, - }), - ); - }), - Effect.catchCause((cause) => - Effect.logWarning( - "provider command reactor failed to generate or rename worktree branch", - { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, + cwd, + oldBranch, + cause: Cause.pretty(cause), + }, + ), ), - ), - ); - }); + ); + }, + ); const processTurnStartRequested = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + ProviderIntentEvent, + { type: "thread.turn-start-requested" } + >, ) { const key = turnStartKeyForEvent(event); if (yield* hasHandledTurnStartRecently(key)) { @@ -439,7 +532,9 @@ const make = Effect.gen(function* () { return; } - const message = thread.messages.find((entry) => entry.id === event.payload.messageId); + const message = thread.messages.find( + (entry) => entry.id === event.payload.messageId, + ); if (!message || message.role !== "user") { yield* appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -458,15 +553,23 @@ const make = Effect.gen(function* () { worktreePath: thread.worktreePath, messageId: message.id, messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), }).pipe(Effect.forkScoped); yield* sendTurnForThread({ 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 } : {}), + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), + ...(event.payload.provider !== undefined + ? { provider: event.payload.provider } + : {}), + ...(event.payload.model !== undefined + ? { model: event.payload.model } + : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), @@ -479,7 +582,10 @@ const make = Effect.gen(function* () { }); const processTurnInterruptRequested = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + ProviderIntentEvent, + { type: "thread.turn-interrupt-requested" } + >, ) { const thread = yield* resolveThread(event.payload.threadId); if (!thread) { @@ -502,7 +608,10 @@ const make = Effect.gen(function* () { }); const processApprovalResponseRequested = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + ProviderIntentEvent, + { type: "thread.approval-response-requested" } + >, ) { const thread = yield* resolveThread(event.payload.threadId); if (!thread) { @@ -547,7 +656,10 @@ const make = Effect.gen(function* () { }); const processUserInputResponseRequested = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + ProviderIntentEvent, + { type: "thread.user-input-response-requested" } + >, ) { const thread = yield* resolveThread(event.payload.threadId); if (!thread) { @@ -588,7 +700,10 @@ const make = Effect.gen(function* () { }); const processSessionStopRequested = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + ProviderIntentEvent, + { type: "thread.session-stop-requested" } + >, ) { const thread = yield* resolveThread(event.payload.threadId); if (!thread) { @@ -623,7 +738,9 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); + const cachedProviderOptions = threadProviderOptions.get( + event.payload.threadId, + ); yield* ensureSessionForThread( event.payload.threadId, event.occurredAt, @@ -657,10 +774,13 @@ const make = Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); } - return Effect.logWarning("provider command reactor failed to process event", { - eventType: event.type, - cause: Cause.pretty(cause), - }); + return Effect.logWarning( + "provider command reactor failed to process event", + { + eventType: event.type, + cause: Cause.pretty(cause), + }, + ); }), ); @@ -689,4 +809,7 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect( + ProviderCommandReactor, + make, +); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7edf..aab2d121fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { + OrchestrationReadModel, + ProviderRuntimeEvent, +} from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -14,7 +17,15 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { + Effect, + Exit, + Layer, + ManagedRuntime, + PubSub, + Scope, + Stream, +} from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -36,7 +47,8 @@ import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asItemId = (value: string): ProviderItemId => ProviderItemId.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); @@ -45,7 +57,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -56,9 +68,12 @@ type LegacyProviderRuntimeEvent = { }; function createProviderServiceHarness() { - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeEventPubSub = Effect.runSync( + PubSub.unbounded(), + ); - const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const unsupported = () => + Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), @@ -66,6 +81,7 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + stopAll: () => Effect.void, listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), @@ -73,7 +89,12 @@ function createProviderServiceHarness() { }; const emit = (event: LegacyProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); + Effect.runSync( + PubSub.publish( + runtimeEventPubSub, + event as unknown as ProviderRuntimeEvent, + ), + ); }; return { @@ -90,7 +111,9 @@ async function waitForThread( const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); if (thread && predicate(thread)) { return thread; } @@ -104,11 +127,15 @@ async function waitForThread( } type ProviderRuntimeTestReadModel = OrchestrationReadModel; -type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number]; +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]; +type ProviderRuntimeTestProposedPlan = + ProviderRuntimeTestThread["proposedPlans"][number]; +type ProviderRuntimeTestActivity = + ProviderRuntimeTestThread["activities"][number]; +type ProviderRuntimeTestCheckpoint = + ProviderRuntimeTestThread["checkpoints"][number]; describe("ProviderRuntimeIngestion", () => { let runtime: ManagedRuntime.ManagedRuntime< @@ -155,8 +182,12 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); + const engine = await runtime.runPromise( + Effect.service(OrchestrationEngineService), + ); + const ingestion = await runtime.runPromise( + Effect.service(ProviderRuntimeIngestionService), + ); scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start.pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); @@ -228,7 +259,9 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, - (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1", + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-1", ); harness.emit({ @@ -273,7 +306,9 @@ describe("ProviderRuntimeIngestion", () => { let thread = await waitForThread( harness.engine, - (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null, + (entry) => + entry.session?.status === "running" && + entry.session?.activeTurnId === null, ); expect(thread.session?.status).toBe("running"); expect(thread.session?.lastError).toBeNull(); @@ -398,7 +433,65 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, - (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + (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, ); }); @@ -418,7 +511,8 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-primary", ); harness.emit({ @@ -451,7 +545,9 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, - (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + (thread) => + thread.session?.status === "ready" && + thread.session?.activeTurnId === null, ); }); @@ -505,7 +601,9 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, - (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + (thread) => + thread.session?.status === "ready" && + thread.session?.activeTurnId === null, ); }); @@ -592,7 +690,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-no-delta", + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-no-delta", ); expect(message?.text).toBe("assistant-only final text"); expect(message?.streaming).toBe(false); @@ -621,7 +720,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const proposedPlan = thread.proposedPlans.find( - (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final", + (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", @@ -644,7 +744,8 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer", + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-plan-buffer", ); harness.emit({ @@ -691,7 +792,9 @@ describe("ProviderRuntimeIngestion", () => { (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer", ); - expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); + expect(proposedPlan?.planMarkdown).toBe( + "## Buffered plan\n\n- first\n- second", + ); }); it("buffers assistant deltas by default until completion", async () => { @@ -709,7 +812,8 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread( harness.engine, (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered", + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered", ); harness.emit({ @@ -733,7 +837,8 @@ describe("ProviderRuntimeIngestion", () => { ); expect( midThread?.messages.some( - (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffered", + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered", ), ).toBe(false); @@ -758,7 +863,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered", + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffered", ); expect(message?.text).toBe("buffer me"); expect(message?.streaming).toBe(false); @@ -825,7 +931,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const liveMessage = liveThread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-mode", + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-streaming-mode", ); expect(liveMessage?.streaming).toBe(true); @@ -851,7 +958,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const finalMessage = finalThread.messages.find( - (entry: ProviderRuntimeTestMessage) => 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); @@ -911,7 +1019,8 @@ describe("ProviderRuntimeIngestion", () => { ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffer-spill", + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffer-spill", ); expect(message?.text.length).toBe(oversizedText.length); expect(message?.text).toBe(oversizedText); @@ -983,7 +1092,8 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.activeTurnId === null && thread.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-complete-dedup" && !message.streaming, + message.id === "assistant:item-complete-dedup" && + !message.streaming, ), ); @@ -1038,19 +1148,24 @@ describe("ProviderRuntimeIngestion", () => { harness.engine, (entry) => entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "approval.requested", ) && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.resolved", + (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")); + 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", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-request-opened", ); const requestedPayload = requested?.payload && typeof requested.payload === "object" @@ -1060,7 +1175,8 @@ describe("ProviderRuntimeIngestion", () => { expect(requestedPayload?.requestType).toBe("command_execution_approval"); const resolved = thread?.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-request-resolved", ); const resolvedPayload = resolved?.payload && typeof resolved.payload === "object" @@ -1133,7 +1249,8 @@ describe("ProviderRuntimeIngestion", () => { entry.session?.activeTurnId === "turn-warning" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => - activity.id === "evt-warning-runtime" && activity.kind === "runtime.warning", + activity.id === "evt-warning-runtime" && + activity.kind === "runtime.warning", ), ); expect(thread.session?.status).toBe("running"); @@ -1181,14 +1298,16 @@ describe("ProviderRuntimeIngestion", () => { entry.session?.status === "ready" && entry.session?.activeTurnId === null && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "tool.started", ), ); expect(thread.session?.status).toBe("ready"); expect( thread.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "tool.started", ), ).toBe(true); }); @@ -1273,23 +1392,28 @@ describe("ProviderRuntimeIngestion", () => { (entry) => entry.title === "Renamed by provider" && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "turn.plan.updated", ) && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "tool.updated", ) && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "runtime.warning", ) && entry.checkpoints.some( - (checkpoint: ProviderRuntimeTestCheckpoint) => checkpoint.turnId === "turn-p1", + (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", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-turn-plan-updated", ); const planPayload = planActivity?.payload && typeof planActivity.payload === "object" @@ -1299,7 +1423,8 @@ describe("ProviderRuntimeIngestion", () => { expect(Array.isArray(planPayload?.plan)).toBe(true); const toolUpdate = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-item-updated", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-item-updated", ); const toolUpdatePayload = toolUpdate?.payload && typeof toolUpdate.payload === "object" @@ -1310,7 +1435,8 @@ describe("ProviderRuntimeIngestion", () => { expect(toolUpdatePayload?.status).toBe("in_progress"); const warning = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-runtime-warning", ); const warningPayload = warning?.payload && typeof warning.payload === "object" @@ -1324,7 +1450,9 @@ describe("ProviderRuntimeIngestion", () => { ); expect(checkpoint?.status).toBe("missing"); expect(checkpoint?.assistantMessageId).toBe("assistant:item-p1-assistant"); - expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); + expect(checkpoint?.checkpointRef).toBe( + "provider-diff:evt-turn-diff-updated", + ); }); it("projects Codex task lifecycle chunks into thread activities", async () => { @@ -1353,7 +1481,8 @@ describe("ProviderRuntimeIngestion", () => { turnId: asTurnId("turn-task-1"), payload: { taskId: "turn-task-1", - description: "Comparing the desktop rollout chunks to the app-server stream.", + description: + "Comparing the desktop rollout chunks to the app-server stream.", }, }); @@ -1386,7 +1515,8 @@ describe("ProviderRuntimeIngestion", () => { harness.engine, (entry) => entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "task.completed", ) && entry.proposedPlans.some( (proposedPlan: ProviderRuntimeTestProposedPlan) => @@ -1395,13 +1525,16 @@ describe("ProviderRuntimeIngestion", () => { ); const started = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-task-started", ); const progress = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-task-progress", ); const completed = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-task-completed", ); const progressPayload = @@ -1420,10 +1553,13 @@ describe("ProviderRuntimeIngestion", () => { "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(completedPayload?.detail).toBe( + "\n# Plan title\n", + ); expect( thread.proposedPlans.find( - (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-task-1", + (entry: ProviderRuntimeTestProposedPlan) => + entry.id === "plan:thread-1:turn:turn-task-1", )?.planMarkdown, ).toBe("# Plan title"); }); @@ -1476,20 +1612,24 @@ describe("ProviderRuntimeIngestion", () => { harness.engine, (entry) => entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "user-input.requested", ) && entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.resolved", + (activity: ProviderRuntimeTestActivity) => + activity.kind === "user-input.resolved", ), ); const requested = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-user-input-requested", + (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", + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-user-input-resolved", ); const resolvedPayload = resolved?.payload && typeof resolved.payload === "object" diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d4..f29426e559 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -11,7 +11,16 @@ import { type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; +import { + Cache, + Cause, + Duration, + Effect, + Layer, + Option, + Ref, + Stream, +} from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -23,9 +32,15 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; -const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; -const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => - CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); +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 TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; @@ -35,7 +50,8 @@ 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"; +const STRICT_PROVIDER_LIFECYCLE_GUARD = + process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< OrchestrationEvent, @@ -56,12 +72,22 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); } -function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { +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 { - if (left === null || left === undefined || right === null || right === undefined) { +function sameId( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + if ( + left === null || + left === undefined || + right === null || + right === undefined + ) { return false; } return left === right; @@ -71,7 +97,9 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } -function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined { +function normalizeProposedPlanMarkdown( + planMarkdown: string | undefined, +): string | undefined { const trimmed = planMarkdown?.trim(); if (!trimmed) { return undefined; @@ -83,7 +111,10 @@ function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } -function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId): string { +function proposedPlanIdFromEvent( + event: ProviderRuntimeEvent, + threadId: ThreadId, +): string { const turnId = toTurnId(event.turnId); if (turnId) { return proposedPlanIdForTurn(threadId, turnId); @@ -98,7 +129,9 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { +function runtimePayloadRecord( + event: ProviderRuntimeEvent, +): Record | undefined { const payload = (event as { payload?: unknown }).payload; if (!payload || typeof payload !== "object") { return undefined; @@ -127,18 +160,31 @@ function runtimeTurnState( return normalizeRuntimeTurnState(payloadState); } -function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined { - const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage); +function runtimeTurnErrorMessage( + event: ProviderRuntimeEvent, +): string | undefined { + const payloadErrorMessage = asString( + runtimePayloadRecord(event)?.errorMessage, + ); return payloadErrorMessage; } -function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined { +function runtimeErrorMessageFromEvent( + event: ProviderRuntimeEvent, +): string | undefined { const payloadMessage = asString(runtimePayloadRecord(event)?.message); return payloadMessage; } function orchestrationSessionStatusFromRuntimeState( - state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", + state: + | "starting" + | "running" + | "waiting" + | "ready" + | "interrupted" + | "stopped" + | "error", ): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { switch (state) { case "starting": @@ -178,7 +224,9 @@ function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { const maybeSequence = (() => { - const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; + const eventWithSequence = event as ProviderRuntimeEvent & { + sessionSequence?: number; + }; return eventWithSequence.sessionSequence !== undefined ? { sequence: eventWithSequence.sessionSequence } : {}; @@ -188,7 +236,9 @@ function runtimeEventToActivities( if (event.payload.requestType === "tool_user_input") { return []; } - const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); + const requestKind = requestKindFromCanonicalRequestType( + event.payload.requestType, + ); return [ { id: event.eventId, @@ -207,7 +257,9 @@ function runtimeEventToActivities( requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.detail + ? { detail: truncateDetail(event.payload.detail) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -219,7 +271,9 @@ function runtimeEventToActivities( if (event.payload.requestType === "tool_user_input") { return []; } - const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); + const requestKind = requestKindFromCanonicalRequestType( + event.payload.requestType, + ); return [ { id: event.eventId, @@ -231,7 +285,9 @@ function runtimeEventToActivities( requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, - ...(event.payload.decision ? { decision: event.payload.decision } : {}), + ...(event.payload.decision + ? { decision: event.payload.decision } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -270,7 +326,9 @@ function runtimeEventToActivities( summary: "Runtime warning", payload: { message: truncateDetail(event.payload.message), - ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + ...(event.payload.detail !== undefined + ? { detail: event.payload.detail } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -349,7 +407,9 @@ function runtimeEventToActivities( : "Task started", payload: { taskId: event.payload.taskId, - ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), + ...(event.payload.taskType + ? { taskType: event.payload.taskType } + : {}), ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), @@ -371,8 +431,12 @@ function runtimeEventToActivities( 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 } : {}), + ...(event.payload.lastToolName + ? { lastToolName: event.payload.lastToolName } + : {}), + ...(event.payload.usage !== undefined + ? { usage: event.payload.usage } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -396,8 +460,12 @@ function runtimeEventToActivities( 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 } : {}), + ...(event.payload.summary + ? { detail: truncateDetail(event.payload.summary) } + : {}), + ...(event.payload.usage !== undefined + ? { usage: event.payload.usage } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -419,8 +487,12 @@ function runtimeEventToActivities( 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 } : {}), + ...(event.payload.detail + ? { detail: truncateDetail(event.payload.detail) } + : {}), + ...(event.payload.data !== undefined + ? { data: event.payload.data } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -441,7 +513,12 @@ function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.detail + ? { detail: truncateDetail(event.payload.detail) } + : {}), + ...(event.payload.data !== undefined + ? { data: event.payload.data } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -462,7 +539,12 @@ function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.detail + ? { detail: truncateDetail(event.payload.detail) } + : {}), + ...(event.payload.data !== undefined + ? { data: event.payload.data } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -491,13 +573,18 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(new Set()), }); - const bufferedAssistantTextByMessageId = yield* Cache.make({ - capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, - timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, - lookup: () => Effect.succeed(""), - }); + const bufferedAssistantTextByMessageId = yield* Cache.make( + { + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(""), + }, + ); - const bufferedProposedPlanById = yield* Cache.make({ + const bufferedProposedPlanById = yield* Cache.make< + string, + { text: string; createdAt: string } + >({ capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, lookup: () => Effect.succeed({ text: "", createdAt: "" }), @@ -519,8 +606,15 @@ const make = Effect.gen(function* () { return isGitRepository(workspaceCwd); }); - const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( + const rememberAssistantMessageId = ( + threadId: ThreadId, + turnId: TurnId, + messageId: MessageId, + ) => + Cache.getOption( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + ).pipe( Effect.flatMap((existingIds) => Cache.set( turnMessageIdsByTurnKey, @@ -537,8 +631,15 @@ const make = Effect.gen(function* () { ), ); - const forgetAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( + const forgetAssistantMessageId = ( + threadId: ThreadId, + turnId: TurnId, + messageId: MessageId, + ) => + Cache.getOption( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + ).pipe( Effect.flatMap((existingIds) => Option.match(existingIds, { onNone: () => Effect.void, @@ -546,23 +647,42 @@ const make = Effect.gen(function* () { const nextIds = new Set(ids); nextIds.delete(messageId); if (nextIds.size === 0) { - return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); + return Cache.invalidate( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + ); } - return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds); + return Cache.set( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + nextIds, + ); }, }), ), ); const getAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( + Cache.getOption( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + ).pipe( Effect.map((existingIds) => - Option.getOrElse(existingIds, (): Set => new Set()), + Option.getOrElse( + existingIds, + (): Set => new Set(), + ), ), ); - const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => - Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); + const clearAssistantMessageIdsForTurn = ( + threadId: ThreadId, + turnId: TurnId, + ) => + Cache.invalidate( + turnMessageIdsByTurnKey, + providerTurnKey(threadId, turnId), + ); const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( @@ -573,7 +693,11 @@ const make = Effect.gen(function* () { onSome: (text) => `${text}${delta}`, }); if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { - yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); + yield* Cache.set( + bufferedAssistantTextByMessageId, + messageId, + nextText, + ); return ""; } @@ -596,14 +720,20 @@ const make = Effect.gen(function* () { const clearBufferedAssistantText = (messageId: MessageId) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId); - const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => + 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, + existing?.createdAt && existing.createdAt.length > 0 + ? existing.createdAt + : createdAt, }); }), ); @@ -684,7 +814,9 @@ const make = Effect.gen(function* () { return; } - const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); + 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"), @@ -714,8 +846,12 @@ const make = Effect.gen(function* () { }) => Effect.gen(function* () { const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); + const bufferedMarkdown = normalizeProposedPlanMarkdown( + bufferedPlan?.text, + ); + const fallbackMarkdown = normalizeProposedPlanMarkdown( + input.fallbackMarkdown, + ); const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; if (!planMarkdown) { return; @@ -742,7 +878,9 @@ const make = 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)); + const proposedPlanKeys = Array.from( + yield* Cache.keys(bufferedProposedPlanById), + ); yield* Effect.forEach( turnKeys, (key) => @@ -751,11 +889,18 @@ const make = Effect.gen(function* () { return; } - const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); + const messageIds = yield* Cache.getOption( + turnMessageIdsByTurnKey, + key, + ); if (Option.isSome(messageIds)) { - yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { - concurrency: 1, - }).pipe(Effect.asVoid); + yield* Effect.forEach( + messageIds.value, + clearAssistantMessageState, + { + concurrency: 1, + }, + ).pipe(Effect.asVoid); } yield* Cache.invalidate(turnMessageIdsByTurnKey, key); @@ -775,7 +920,9 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); + const thread = readModel.threads.find( + (entry) => entry.id === event.threadId, + ); if (!thread) return; const now = event.createdAt; @@ -783,8 +930,11 @@ const make = Effect.gen(function* () { const activeTurnId = thread.session?.activeTurnId ?? null; const conflictsWithActiveTurn = - activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); - const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + activeTurnId !== null && + eventTurnId !== undefined && + !sameId(activeTurnId, eventTurnId); + const missingTurnForActiveTurn = + activeTurnId !== null && eventTurnId === undefined; const shouldApplyThreadLifecycle = (() => { if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { @@ -830,7 +980,9 @@ const make = Effect.gen(function* () { const status = (() => { switch (event.type) { case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); + return orchestrationSessionStatusFromRuntimeState( + event.payload.state, + ); case "turn.started": return "running"; case "session.exited": @@ -845,10 +997,16 @@ const make = Effect.gen(function* () { } })(); const lastError = - 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") + 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); @@ -873,7 +1031,8 @@ const make = Effect.gen(function* () { } const assistantDelta = - event.type === "content.delta" && event.payload.streamKind === "assistant_text" + event.type === "content.delta" && + event.payload.streamKind === "assistant_text" ? event.payload.delta : undefined; const proposedPlanDelta = @@ -885,16 +1044,26 @@ const make = Effect.gen(function* () { ); const turnId = toTurnId(event.turnId); if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + yield* rememberAssistantMessageId( + thread.id, + turnId, + assistantMessageId, + ); } const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + commandId: providerCommandId( + event, + "assistant-delta-buffer-spill", + ), threadId: thread.id, messageId: assistantMessageId, delta: spillChunk, @@ -921,7 +1090,8 @@ const make = Effect.gen(function* () { } const assistantCompletion = - event.type === "item.completed" && event.payload.itemType === "assistant_message" + event.type === "item.completed" && + event.payload.itemType === "assistant_message" ? { messageId: MessageId.makeUnsafe( `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, @@ -945,9 +1115,14 @@ const make = Effect.gen(function* () { (entry) => entry.id === assistantMessageId, ); const shouldApplyFallbackCompletionText = - !existingAssistantMessage || existingAssistantMessage.text.length === 0; + !existingAssistantMessage || + existingAssistantMessage.text.length === 0; if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + yield* rememberAssistantMessageId( + thread.id, + turnId, + assistantMessageId, + ); } yield* finalizeAssistantMessage({ @@ -958,13 +1133,18 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete", finalDeltaCommandTag: "assistant-delta-finalize", - ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText + ...(assistantCompletion.fallbackText !== undefined && + shouldApplyFallbackCompletionText ? { fallbackText: assistantCompletion.fallbackText } : {}), }); if (turnId) { - yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + yield* forgetAssistantMessageId( + thread.id, + turnId, + assistantMessageId, + ); } } @@ -974,7 +1154,9 @@ const make = Effect.gen(function* () { threadId: thread.id, threadProposedPlans: thread.proposedPlans, planId: proposedPlanCompletion.planId, - ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), + ...(proposedPlanCompletion.turnId + ? { turnId: proposedPlanCompletion.turnId } + : {}), fallbackMarkdown: proposedPlanCompletion.planMarkdown, updatedAt: now, }); @@ -983,7 +1165,10 @@ const make = Effect.gen(function* () { if (event.type === "turn.completed") { const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + const assistantMessageIds = yield* getAssistantMessageIdsForTurn( + thread.id, + turnId, + ); yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => @@ -1016,11 +1201,14 @@ const make = Effect.gen(function* () { } if (event.type === "runtime.error") { - const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; + const runtimeErrorMessage = + runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true - : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + : activeTurnId === null || + eventTurnId === undefined || + sameId(activeTurnId, eventTurnId); if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ @@ -1073,7 +1261,9 @@ const make = Effect.gen(function* () { threadId: thread.id, turnId, completedAt: now, - checkpointRef: CheckpointRef.makeUnsafe(`provider-diff:${event.eventId}`), + checkpointRef: CheckpointRef.makeUnsafe( + `provider-diff:${event.eventId}`, + ), status: "missing", files: [], assistantMessageId, @@ -1103,7 +1293,9 @@ const make = Effect.gen(function* () { ); const processInput = (input: RuntimeIngestionInput) => - input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); + input.source === "runtime" + ? processRuntimeEvent(input.event) + : processDomainEvent(input.event); const processInputSafely = (input: RuntimeIngestionInput) => processInput(input).pipe( @@ -1111,32 +1303,37 @@ const make = Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); } - return Effect.logWarning("provider runtime ingestion failed to process event", { - source: input.source, - eventId: input.event.eventId, - eventType: input.event.type, - cause: Cause.pretty(cause), - }); + return Effect.logWarning( + "provider runtime ingestion failed to process event", + { + source: input.source, + eventId: input.event.eventId, + eventType: input.event.type, + cause: Cause.pretty(cause), + }, + ); }), ); const worker = yield* makeDrainableWorker(processInputSafely); - const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () { - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - worker.enqueue({ source: "runtime", event }), - ), - ); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); - }); + const start: ProviderRuntimeIngestionShape["start"] = Effect.gen( + function* () { + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => + worker.enqueue({ source: "runtime", event }), + ), + ); + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }), + ); + }, + ); return { start, @@ -1144,4 +1341,7 @@ const make = Effect.gen(function* () { } satisfies ProviderRuntimeIngestionShape; }); -export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make); +export const ProviderRuntimeIngestionLive = Layer.effect( + ProviderRuntimeIngestionService, + make, +); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index b121764fcc..aa17cdb2f5 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -74,7 +74,8 @@ const makeWithDatabase = ( if (cached !== undefined) { return cached; } - const value = statement.columns().length > 0; + const value = + typeof statement.columns === "function" ? statement.columns().length > 0 : true; statementReaderCache.set(statement, value); return value; }; @@ -110,31 +111,45 @@ const makeWithDatabase = ( const run = (sql: string, params: ReadonlyArray, raw = false) => Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); + const testStatement = db.prepare("SELECT 1"); + const supportsReturnArrays = typeof testStatement.setReturnArrays === "function"; + const runValues = (sql: string, params: ReadonlyArray) => - Effect.acquireUseRelease( - Cache.get(prepareCache, sql), - (statement) => - Effect.try({ - try: () => { - if (hasRows(statement)) { - statement.setReturnArrays(true); - // Safe to cast to array after we've setReturnArrays(true) - return statement.all(...(params as any)) as unknown as ReadonlyArray< + supportsReturnArrays + ? Effect.acquireUseRelease( + Cache.get(prepareCache, sql), + (statement) => + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(true); + return statement.all(...(params as any)) as unknown as ReadonlyArray< + ReadonlyArray + >; + } + statement.run(...(params as any)); + return []; + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + }), + (statement) => + Effect.sync(() => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }), + ) + : Effect.flatMap(Cache.get(prepareCache, sql), (statement) => + Effect.try({ + try: () => { + const rows = statement.all(...(params as any)); + return rows.map((row: any) => Object.values(row)) as ReadonlyArray< ReadonlyArray >; - } - statement.run(...(params as any)); - return []; - }, - catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), - }), - (statement) => - Effect.sync(() => { - if (hasRows(statement)) { - statement.setReturnArrays(false); - } - }), - ); + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + }), + ); return identity({ execute(sql, params, rowTransform) { 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..c316532e60 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -0,0 +1,1922 @@ +/** + * 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; + readonly inputChunks: string[]; +} + +interface ClaudeSessionContext { + session: ProviderSession; + readonly inputThreadId: ThreadId; + 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("thread-claude-"); +} + +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 tryParseJson(raw: string): Record | null { + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function rebuildToolDetail(tool: ToolInFlight): string | undefined { + if (tool.inputChunks.length === 0) { + return tool.detail; + } + const parsed = tryParseJson(tool.inputChunks.join("")); + return parsed ? summarizeToolRequest(tool.toolName, parsed) : tool.detail; +} + +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; + + if (context.session.threadId === undefined) { + context.session = { + ...context.session, + threadId: ThreadId.makeUnsafe(nextThreadId), + }; + } + + 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, + }, + }); + } + if (event.delta.type === "input_json_delta") { + const tool = context.inFlightTools.get(event.index); + if (tool) { + tool.inputChunks.push(event.delta.partial_json); + } + } + 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, + inputChunks: [], + }; + 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 completedDetail = rebuildToolDetail(tool); + + 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, + ...(completedDetail ? { detail: completedDetail } : {}), + }, + 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.inputThreadId); + }); + + const resolveSession = ( + threadId: ThreadId | undefined, + ): ClaudeSessionContext | undefined => { + if (threadId !== undefined) { + const direct = sessions.get(threadId); + if (direct) return direct; + } + for (const ctx of sessions.values()) { + if ( + ctx.session.threadId === threadId || + ctx.inputThreadId === threadId || + threadId === undefined + ) { + return ctx; + } + } + return undefined; + }; + + const requireSession = ( + threadId: ThreadId | undefined, + ): Effect.Effect => { + const context = resolveSession(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: threadId ?? ThreadId.makeUnsafe("unknown"), + }), + ); + } + if (context.stopped || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: context.inputThreadId, + }), + ); + } + 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 providerThreadId = resumeState?.threadId + ?? (input.threadId && !isSyntheticClaudeThreadId(input.threadId) + ? input.threadId + : undefined); + 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 is intentionally deferred until the SDK emits a real + // session_id — the spread below keeps it undefined for synthetic IDs + // so that callers never see a fabricated provider thread id. + threadId: providerThreadId as ThreadId, + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + ...(providerThreadId ? { threadId: providerThreadId } : {}), + resumeCursor: { + ...(providerThreadId ? { threadId: providerThreadId } : {}), + ...(resumeState?.resume ? { resume: resumeState.resume } : {}), + ...(resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), + turnCount: resumeState?.turnCount ?? 0, + }, + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: ClaudeSessionContext = { + session, + inputThreadId: input.threadId, + 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(input.threadId, context); + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + ...(providerThreadId ? { threadId: providerThreadId } : {}), + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: {}, + } as ProviderRuntimeEvent); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + createdAt: configuredStamp.createdAt, + ...(providerThreadId ? { threadId: providerThreadId } : {}), + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + }, + }, + providerRefs: {}, + } as ProviderRuntimeEvent); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + ...(providerThreadId ? { threadId: providerThreadId } : {}), + payload: { + state: "ready", + }, + providerRefs: {}, + } as ProviderRuntimeEvent); + + 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 3ad206d0be..86ceb864ea 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -22,6 +22,7 @@ import { type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; +import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; @@ -29,11 +30,14 @@ import { makeCodexAdapterLive } from "./CodexAdapter.ts"; 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); +const asItemId = (value: string): ProviderItemId => + ProviderItemId.makeUnsafe(value); class FakeCodexManager extends CodexAppServerManager { public startSessionImpl = vi.fn( - async (input: CodexAppServerStartSessionInput): Promise => { + async ( + input: CodexAppServerStartSessionInput, + ): Promise => { const now = new Date().toISOString(); return { provider: "codex", @@ -48,7 +52,9 @@ class FakeCodexManager extends CodexAppServerManager { ); public sendTurnImpl = vi.fn( - async (_input: CodexAppServerSendTurnInput): Promise => ({ + async ( + _input: CodexAppServerSendTurnInput, + ): Promise => ({ threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), }), @@ -63,10 +69,12 @@ class FakeCodexManager extends CodexAppServerManager { turns: [], })); - public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({ - threadId: asThreadId("thread-1"), - turns: [], - })); + public rollbackThreadImpl = vi.fn( + async (_threadId: ThreadId, _numTurns: number) => ({ + threadId: asThreadId("thread-1"), + turns: [], + }), + ); public respondToRequestImpl = vi.fn( async ( @@ -86,11 +94,15 @@ class FakeCodexManager extends CodexAppServerManager { public stopAllImpl = vi.fn(() => undefined); - override startSession(input: CodexAppServerStartSessionInput): Promise { + override startSession( + input: CodexAppServerStartSessionInput, + ): Promise { return this.startSessionImpl(input); } - override sendTurn(input: CodexAppServerSendTurnInput): Promise { + override sendTurn( + input: CodexAppServerSendTurnInput, + ): Promise { return this.sendTurnImpl(input); } @@ -137,14 +149,19 @@ class FakeCodexManager extends CodexAppServerManager { } } -const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { - upsert: () => Effect.void, - getProvider: () => - Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), - remove: () => Effect.void, - listThreadIds: () => Effect.succeed([]), -}); +const providerSessionDirectoryTestLayer = Layer.succeed( + ProviderSessionDirectory, + { + upsert: () => Effect.void, + getProvider: () => + Effect.die( + new Error("ProviderSessionDirectory.getProvider is not used in test"), + ), + getBinding: () => Effect.succeed(Option.none()), + remove: () => Effect.void, + listThreadIds: () => Effect.succeed([]), + }, +); const validationManager = new FakeCodexManager(); const validationLayer = it.layer( @@ -156,6 +173,31 @@ const validationLayer = it.layer( ); validationLayer("CodexAdapterLive validation", (it) => { + it.effect("rejects provider mismatch for non-codex provider", () => + Effect.gen(function* () { + validationManager.startSessionImpl.mockClear(); + const adapter = yield* CodexAdapter; + const result = yield* adapter + .startSession({ + provider: "claudeCode", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "Expected provider 'codex' but received 'claudeCode'.", + }), + ); + assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); + }), + ); + it.effect("maps codex model options before starting a session", () => Effect.gen(function* () { validationManager.startSessionImpl.mockClear(); @@ -173,13 +215,16 @@ validationLayer("CodexAdapterLive validation", (it) => { 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", - }); + assert.deepStrictEqual( + validationManager.startSessionImpl.mock.calls[0]?.[0], + { + provider: "codex", + threadId: asThreadId("thread-1"), + model: "gpt-5.3-codex", + serviceTier: "fast", + runtimeMode: "full-access", + }, + ); }), ); }); @@ -197,30 +242,35 @@ const sessionErrorLayer = it.layer( ); sessionErrorLayer("CodexAdapterLive session errors", (it) => { - it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const result = yield* adapter - .sendTurn({ - threadId: asThreadId("sess-missing"), - input: "hello", - attachments: [], - }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } + it.effect( + "maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .sendTurn({ + threadId: asThreadId("sess-missing"), + input: "hello", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } - assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { - return; - } - assert.equal(result.failure.provider, "codex"); - assert.equal(result.failure.threadId, "sess-missing"); - assert.equal(result.failure.cause instanceof Error, true); - }), + assert.equal( + result.failure._tag, + "ProviderAdapterSessionNotFoundError", + ); + if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { + return; + } + assert.equal(result.failure.provider, "codex"); + 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", () => @@ -243,13 +293,16 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], { - threadId: asThreadId("sess-missing"), - input: "hello", - model: "gpt-5.3-codex", - effort: "high", - serviceTier: "fast", - }); + assert.deepStrictEqual( + sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], + { + threadId: asThreadId("sess-missing"), + input: "hello", + model: "gpt-5.3-codex", + effort: "high", + serviceTier: "fast", + }, + ); }), ); }); @@ -264,88 +317,101 @@ const lifecycleLayer = it.layer( ); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { - 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); + 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); - const event: ProviderEvent = { - id: asEventId("evt-msg-complete"), - kind: "notification", - provider: "codex", - createdAt: new Date().toISOString(), - method: "item/completed", - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-1"), - itemId: asItemId("msg_1"), - payload: { - item: { - type: "agentMessage", - id: "msg_1", + const event: ProviderEvent = { + id: asEventId("evt-msg-complete"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_1"), + payload: { + item: { + type: "agentMessage", + id: "msg_1", + }, }, - }, - }; + }; - lifecycleManager.emit("event", event); - const firstEvent = yield* Fiber.join(firstEventFiber); + 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, "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"); - }), + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + 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); + 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", + 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); + 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"); - }), + 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); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild, + ); lifecycleManager.emit("event", { id: asEventId("evt-plan-delta"), @@ -376,41 +442,47 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + it.effect( + "maps session/closed lifecycle events to canonical session.exited runtime events", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead( + adapter.streamEvents, + ).pipe(Effect.forkChild); - const event: ProviderEvent = { - id: asEventId("evt-session-closed"), - kind: "session", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "session/closed", - message: "Session stopped", - }; + const event: ProviderEvent = { + id: asEventId("evt-session-closed"), + kind: "session", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "session/closed", + message: "Session stopped", + }; - lifecycleManager.emit("event", event); - const firstEvent = yield* Fiber.join(firstEventFiber); + 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, "session.exited"); - if (firstEvent.value.type !== "session.exited") { - return; - } - assert.equal(firstEvent.value.threadId, "thread-1"); - assert.equal(firstEvent.value.payload.reason, "Session stopped"); - }), + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "session.exited"); + if (firstEvent.value.type !== "session.exited") { + return; + } + assert.equal(firstEvent.value.threadId, "thread-1"); + assert.equal(firstEvent.value.payload.reason, "Session stopped"); + }), ); it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild, + ); lifecycleManager.emit("event", { id: asEventId("evt-retryable-error"), @@ -446,7 +518,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { 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 firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild, + ); const event: ProviderEvent = { id: asEventId("evt-request-resolved"), @@ -475,50 +549,62 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (firstEvent.value.type !== "request.resolved") { return; } - assert.equal(firstEvent.value.payload.requestType, "command_execution_approval"); + assert.equal( + firstEvent.value.payload.requestType, + "command_execution_approval", + ); }), ); - it.effect("preserves file-read request type when mapping serverRequest/resolved", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + it.effect( + "preserves file-read request type when mapping serverRequest/resolved", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead( + adapter.streamEvents, + ).pipe(Effect.forkChild); - const event: ProviderEvent = { - id: asEventId("evt-file-read-request-resolved"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "serverRequest/resolved", - requestId: ApprovalRequestId.makeUnsafe("req-file-read-1"), - payload: { - request: { - method: "item/fileRead/requestApproval", + const event: ProviderEvent = { + id: asEventId("evt-file-read-request-resolved"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "serverRequest/resolved", + requestId: ApprovalRequestId.makeUnsafe("req-file-read-1"), + payload: { + request: { + method: "item/fileRead/requestApproval", + }, + decision: "accept", }, - decision: "accept", - }, - }; + }; - lifecycleManager.emit("event", event); - const firstEvent = yield* Fiber.join(firstEventFiber); + lifecycleManager.emit("event", event); + const firstEvent = yield* Fiber.join(firstEventFiber); - assert.equal(firstEvent._tag, "Some"); - if (firstEvent._tag !== "Some") { - return; - } - assert.equal(firstEvent.value.type, "request.resolved"); - if (firstEvent.value.type !== "request.resolved") { - return; - } - assert.equal(firstEvent.value.payload.requestType, "file_read_approval"); - }), + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "request.resolved"); + if (firstEvent.value.type !== "request.resolved") { + return; + } + assert.equal( + firstEvent.value.payload.requestType, + "file_read_approval", + ); + }), ); it.effect("preserves explicit empty multi-select user-input answers", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; - const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe( + Effect.forkChild, + ); const event: ProviderEvent = { id: asEventId("evt-user-input-empty"), @@ -551,46 +637,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - 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, - ); + 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", - }, - }; + 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)); + lifecycleManager.emit("event", event); + const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events.length, 2); + assert.equal(events.length, 2); - const firstEvent = events[0]; - const secondEvent = events[1]; + 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(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"); - } - }), + assert.equal(secondEvent?.type, "runtime.warning"); + if (secondEvent?.type === "runtime.warning") { + assert.equal(secondEvent.payload.message, "Sandbox setup failed"); + } + }), ); it.effect( @@ -598,9 +686,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { () => Effect.gen(function* () { const adapter = yield* CodexAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( - Effect.forkChild, - ); + const eventsFiber = yield* Stream.runCollect( + Stream.take(adapter.streamEvents, 2), + ).pipe(Effect.forkChild); lifecycleManager.emit("event", { id: asEventId("evt-user-input-requested"), @@ -660,121 +748,127 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - 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, - ); + 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", + 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); + } 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.", + 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); + } 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, + 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); + } 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", + 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); + } satisfies ProviderEvent); - const events = Array.from(yield* Fiber.join(eventsFiber)); + 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[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[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[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[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"); - } - }), + 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"); + } + }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c6f4a3c08c..cb8b8876fe 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,10 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { + ClaudeCodeAdapter, + ClaudeCodeAdapterShape, +} from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -27,9 +31,32 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeClaudeAdapter: ClaudeCodeAdapterShape = { + provider: "claudeCode", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const 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, ), ); @@ -39,18 +66,25 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; 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"]); }), ); it.effect("fails with ProviderUnsupportedError for unknown providers", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; - const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); - assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); + const adapter = yield* registry + .getByProvider("unknown" as ProviderKind) + .pipe(Effect.result); + assertFailure( + adapter, + new ProviderUnsupportedError({ provider: "unknown" }), + ); }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3062ed7907..61fa2d18cd 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -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 d5cf4424b1..281149033d 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -44,7 +44,8 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; -const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.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); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); @@ -52,7 +53,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -64,7 +65,9 @@ type LegacyProviderRuntimeEvent = { function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const sessions = new Map(); - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeEventPubSub = Effect.runSync( + PubSub.unbounded(), + ); const startSession = vi.fn((input: ProviderSessionStartInput) => Effect.sync(() => { @@ -74,7 +77,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { status: "ready", runtimeMode: input.runtimeMode, threadId: input.threadId, - resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` }, + resumeCursor: input.resumeCursor ?? { + opaque: `cursor-${String(input.threadId)}`, + }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -105,8 +110,10 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ); const interruptTurn = vi.fn( - (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect => - Effect.void, + ( + _threadId: ThreadId, + _turnId?: TurnId, + ): Effect.Effect => Effect.void, ); const respondToRequest = vi.fn( @@ -138,7 +145,8 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ); const hasSession = vi.fn( - (threadId: ThreadId): Effect.Effect => Effect.succeed(sessions.has(threadId)), + (threadId: ThreadId): Effect.Effect => + Effect.succeed(sessions.has(threadId)), ); const readThread = vi.fn( @@ -161,8 +169,10 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ( threadId: ThreadId, _numTurns: number, - ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> => - Effect.succeed({ threadId, turns: [] }), + ): Effect.Effect< + { threadId: ThreadId; turns: readonly [] }, + ProviderAdapterError + > => Effect.succeed({ threadId, turns: [] }), ); const stopAll = vi.fn( @@ -192,7 +202,12 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { }; const emit = (event: LegacyProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); + Effect.runSync( + PubSub.publish( + runtimeEventPubSub, + event as unknown as ProviderRuntimeEvent, + ), + ); }; return { @@ -217,19 +232,24 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeCode"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); const layer = it.layer( Layer.mergeAll( @@ -247,80 +267,91 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } const routing = makeProviderServiceLayer(); -it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); - - const codex = makeFakeCodexAdapter(); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; +it.effect( + "ProviderServiceLive keeps persisted resumable sessions on startup", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "t3-provider-service-"), + ); + const dbPath = path.join(tempDir, "orchestration.sqlite"); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const codex = makeFakeCodexAdapter(); + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; - yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - yield* directory.upsert({ - provider: "codex", - threadId: ThreadId.makeUnsafe("thread-stale"), - }); - }).pipe(Effect.provide(directoryLayer)); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); - const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), - Layer.provide(directoryLayer), - Layer.provide(AnalyticsService.layerTest), - ); + yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + yield* directory.upsert({ + provider: "codex", + threadId: ThreadId.makeUnsafe("thread-stale"), + }); + }).pipe(Effect.provide(directoryLayer)); + + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + yield* Effect.gen(function* () { + yield* ProviderService; + }).pipe(Effect.provide(providerLayer)); + + const persistedProvider = yield* Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + return yield* directory.getProvider(asThreadId("thread-stale")); + }).pipe(Effect.provide(directoryLayer)); + assert.equal(persistedProvider, "codex"); - yield* Effect.gen(function* () { - yield* ProviderService; - }).pipe(Effect.provide(providerLayer)); - - const persistedProvider = yield* Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - 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.getByThreadId({ threadId: asThreadId("thread-stale") }); - }).pipe(Effect.provide(runtimeRepositoryLayer)); - assert.equal(Option.isSome(runtime), true); - - const legacyTableRows = yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - return yield* sql<{ readonly name: string }>` + const runtime = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntimeRepository; + return yield* repository.getByThreadId({ + threadId: asThreadId("thread-stale"), + }); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + assert.equal(Option.isSome(runtime), true); + + const legacyTableRows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly name: string }>` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_sessions' `; - }).pipe(Effect.provide(persistenceLayer)); - assert.equal(legacyTableRows.length, 0); + }).pipe(Effect.provide(persistenceLayer)); + assert.equal(legacyTableRows.length, 0); - fs.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "t3-provider-service-restart-"), + ); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( @@ -358,12 +389,17 @@ it.effect( const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getByThreadId({ threadId: startedSession.threadId }); + 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.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); + assert.deepEqual( + persistedAfterStopAll.value.resumeCursor, + startedSession.resumeCursor, + ); } const secondCodex = makeFakeCodexAdapter(); @@ -396,7 +432,10 @@ it.effect( assert.equal(secondCodex.startSession.mock.calls.length, 1); const resumedStartInput = secondCodex.startSession.mock.calls[0]?.[0]; - assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + assert.equal( + typeof resumedStartInput === "object" && resumedStartInput !== null, + true, + ); if (resumedStartInput && typeof resumedStartInput === "object") { const startPayload = resumedStartInput as { provider?: string; @@ -406,7 +445,10 @@ it.effect( }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project"); - assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); + assert.deepEqual( + startPayload.resumeCursor, + startedSession.resumeCursor, + ); assert.equal(startPayload.threadId, startedSession.threadId); } assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); @@ -442,7 +484,9 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(routing.codex.sendTurn.mock.calls.length, 1); yield* provider.interruptTurn({ threadId: session.threadId }); - assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [ + [session.threadId, undefined], + ]); yield* provider.respondToRequest({ threadId: session.threadId, @@ -493,44 +537,83 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => - Effect.gen(function* () { - const provider = yield* ProviderService; + it.effect( + "recovers stale persisted sessions for rollback by resuming thread identity", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; - 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.threadId); - routing.codex.startSession.mockClear(); - routing.codex.rollbackThread.mockClear(); + 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.threadId); + routing.codex.startSession.mockClear(); + routing.codex.rollbackThread.mockClear(); - yield* provider.rollbackConversation({ - threadId: initial.threadId, - numTurns: 1, - }); + yield* provider.rollbackConversation({ + threadId: initial.threadId, + numTurns: 1, + }); - 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); - }), + 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", () => @@ -556,7 +639,10 @@ routing.layer("ProviderServiceLive routing", (it) => { 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); + assert.equal( + typeof resumedStartInput === "object" && resumedStartInput !== null, + true, + ); if (resumedStartInput && typeof resumedStartInput === "object") { const startPayload = resumedStartInput as { provider?: string; @@ -589,53 +675,75 @@ routing.layer("ProviderServiceLive routing", (it) => { }); yield* routing.codex.stopAll(); + yield* routing.claude.stopAll(); const remaining = yield* provider.listSessions(); assert.equal(remaining.length, 0); }), ); - it.effect("persists runtime status transitions in provider_session_runtime", () => - Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + it.effect( + "persists runtime status transitions in provider_session_runtime", + () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const session = yield* provider.startSession(asThreadId("thread-1"), { - provider: "codex", - threadId: asThreadId("thread-1"), - runtimeMode: "full-access", - }); - yield* provider.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); + const session = yield* provider.startSession(asThreadId("thread-1"), { + provider: "codex", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); - 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.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)) { - const runtimePayload = payload as { - cwd: string; - model: string | null; - activeTurnId: string | null; - lastError: string | null; - lastRuntimeEvent: string | null; - }; - assert.equal(runtimePayload.cwd, process.cwd()); - assert.equal(runtimePayload.model, null); - assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); - assert.equal(runtimePayload.lastError, null); - assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); + 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.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) + ) { + const runtimePayload = payload as { + cwd: string; + model: string | null; + activeTurnId: string | null; + lastError: string | null; + lastRuntimeEvent: string | null; + }; + assert.equal(runtimePayload.cwd, process.cwd()); + assert.equal(runtimePayload.model, null); + 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.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(stoppedRuntime), true); + if (Option.isSome(stoppedRuntime)) { + assert.equal(stoppedRuntime.value.status, "stopped"); + } + }), ); }); @@ -651,8 +759,9 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }); const eventsRef = yield* Ref.make>([]); - const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => - Ref.update(eventsRef, (current) => [...current, event]), + const consumer = yield* Stream.runForEach( + provider.streamEvents, + (event) => Ref.update(eventsRef, (current) => [...current, event]), ).pipe(Effect.forkChild); yield* sleep(20); @@ -690,7 +799,9 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const receivedRef = yield* Ref.make>([]); const consumer = yield* Stream.take(provider.streamEvents, 3).pipe( - Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])), + Stream.runForEach((event) => + Ref.update(receivedRef, (current) => [...current, event]), + ), Effect.forkChild, ); yield* sleep(20); @@ -729,80 +840,92 @@ fanout.layer("ProviderServiceLive fanout", (it) => { 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")], + [ + 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[] = []; - const expectedEventIds = new Set(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]); - const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( - Stream.runForEach((event) => - Effect.sync(() => { - receivedByHealthy.push(event.eventId); - }), - ), - Effect.forkChild, - ); - const failingFiber = yield* Stream.take(provider.streamEvents, 1).pipe( - Stream.runForEach(() => Effect.fail("listener crash")), - Effect.forkChild, - ); - yield* sleep(20); - - const events: ReadonlyArray = [ - { - type: "tool.completed", - eventId: asEventId("evt-ordered-1"), - provider: "codex", - createdAt: new Date().toISOString(), - threadId: session.threadId, - turnId: asTurnId("turn-1"), - toolKind: "command", - title: "Ran command", - detail: "echo one", - }, - { - type: "message.delta", - eventId: asEventId("evt-ordered-2"), - provider: "codex", - createdAt: new Date().toISOString(), - threadId: session.threadId, - turnId: asTurnId("turn-1"), - delta: "hello", - }, - { - type: "turn.completed", - eventId: asEventId("evt-ordered-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", - createdAt: new Date().toISOString(), - threadId: session.threadId, - turnId: asTurnId("turn-1"), - status: "completed", - }, - ]; + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); - for (const event of events) { - fanout.codex.emit(event); - } - const failingResult = yield* Effect.result(Fiber.join(failingFiber)); - assert.equal(failingResult._tag, "Failure"); - yield* Fiber.join(healthyFiber); + const receivedByHealthy: string[] = []; + const expectedEventIds = new Set([ + "evt-ordered-1", + "evt-ordered-2", + "evt-ordered-3", + ]); + const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + receivedByHealthy.push(event.eventId); + }), + ), + Effect.forkChild, + ); + const failingFiber = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runForEach(() => Effect.fail("listener crash")), + Effect.forkChild, + ); + yield* sleep(20); - assert.deepEqual( - receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), - ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], - ); - }), + const events: ReadonlyArray = [ + { + type: "tool.completed", + eventId: asEventId("evt-ordered-1"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + toolKind: "command", + title: "Ran command", + detail: "echo one", + }, + { + type: "message.delta", + eventId: asEventId("evt-ordered-2"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + delta: "hello", + }, + { + type: "turn.completed", + eventId: asEventId("evt-ordered-3"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: session.threadId, + turnId: asTurnId("turn-1"), + status: "completed", + }, + ]; + + for (const event of events) { + fanout.codex.emit(event); + } + const failingResult = yield* Effect.result(Fiber.join(failingFiber)); + assert.equal(failingResult._tag, "Failure"); + yield* Fiber.join(healthyFiber); + + assert.deepEqual( + receivedByHealthy + .filter((eventId) => expectedEventIds.has(eventId)) + .slice(0, 3), + ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], + ); + }), ); }); @@ -833,42 +956,48 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); - it.effect("accepts startSession when adapter has not emitted provider thread id yet", () => - Effect.gen(function* () { - const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + 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 { + provider: "codex", + status: "ready", + threadId: input.threadId, + runtimeMode: input.runtimeMode, + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }), + ); - validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => - Effect.sync(() => { - const now = new Date().toISOString(); - return { + const session = yield* provider.startSession( + asThreadId("thread-missing"), + { provider: "codex", - status: "ready", - threadId: input.threadId, - runtimeMode: input.runtimeMode, - cwd: input.cwd ?? process.cwd(), - createdAt: now, - updatedAt: now, - } satisfies ProviderSession; - }), - ); - - const session = yield* provider.startSession(asThreadId("thread-missing"), { - provider: "codex", - threadId: asThreadId("thread-missing"), - cwd: "/tmp/project", - runtimeMode: "full-access", - }); + threadId: asThreadId("thread-missing"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }, + ); - assert.equal(session.threadId, asThreadId("thread-missing")); + 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); - } - }), + 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 8e3bc72041..4bf1135bfb 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -534,6 +534,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => respondToRequest, respondToUserInput, stopSession, + stopAll: runStopAll, listSessions, getCapabilities, rollbackConversation, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0e..ace03d49d8 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -163,6 +163,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); @@ -204,4 +205,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 38e097e1c9..0897e7458d 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -2,7 +2,10 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; +import { + ProviderSessionDirectoryPersistenceError, + ProviderValidationError, +} from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, @@ -22,7 +25,11 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if ( + providerName === "codex" || + providerName === "claudeCode" || + providerName === "cursor" + ) { return Effect.succeed(providerName); } return Effect.fail( @@ -55,12 +62,17 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId"), + ), Effect.flatMap((runtime) => Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( + decodeProviderKind( + value.providerName, + "ProviderSessionDirectory.getBinding", + ).pipe( Effect.map((provider) => Option.some({ threadId: value.threadId, @@ -77,46 +89,64 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { - const existing = yield* repository - .getByThreadId({ threadId: binding.threadId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", - }); - } - - const now = new Date().toISOString(); - const providerChanged = - existingRuntime !== undefined && existingRuntime.providerName !== binding.provider; - yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); - }); + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn( + function* (binding) { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"), + ), + ); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }); + } + + const now = new Date().toISOString(); + const providerChanged = + existingRuntime !== undefined && + existingRuntime.providerName !== binding.provider; + yield* repository + .upsert({ + threadId: resolvedThreadId, + providerName: binding.provider, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: + binding.runtimeMode ?? + existingRuntime?.runtimeMode ?? + "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.upsert:upsert"), + ), + ); + }, + ); - const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => + const getProvider: ProviderSessionDirectoryShape["getProvider"] = ( + threadId, + ) => getBinding(threadId).pipe( Effect.flatMap((binding) => Option.match(binding, { @@ -136,12 +166,18 @@ const makeProviderSessionDirectory = Effect.gen(function* () { repository .deleteByThreadId({ threadId }) .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), + Effect.mapError( + toPersistenceError( + "ProviderSessionDirectory.remove:deleteByThreadId", + ), + ), ); const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.listThreadIds:list"), + ), Effect.map((rows) => rows.map((row) => row.threadId)), ); 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/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index ebfe8c8ab1..cfd5213fc5 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -99,6 +99,11 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Stop all active provider sessions. + */ + readonly stopAll: () => Effect.Effect; + /** * Canonical provider runtime event stream. * @@ -110,6 +115,7 @@ export interface ProviderServiceShape { /** * ProviderService - Service tag for provider orchestration. */ -export class ProviderService extends ServiceMap.Service()( - "t3/provider/Services/ProviderService", -) {} +export class ProviderService extends ServiceMap.Service< + ProviderService, + ProviderServiceShape +>()("t3/provider/Services/ProviderService") {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96f..dea77b9fc9 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; +import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; @@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = 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/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..d3ea9752ee 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -4,12 +4,23 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; +import { + Effect, + Exit, + Layer, + PlatformError, + PubSub, + Scope, + Stream, +} from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; import { ServerConfig, type ServerConfigShape } from "./config"; -import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { + makeServerProviderLayer, + makeServerRuntimeServicesLayer, +} from "./serverLayers"; import { DEFAULT_TERMINAL_ID, @@ -31,7 +42,10 @@ import { type WsPushMessage, type WsPush, } from "@t3tools/contracts"; -import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; +import { + compileResolvedKeybindingRule, + DEFAULT_KEYBINDINGS, +} from "./keybindings"; import type { TerminalClearInput, TerminalCloseInput, @@ -41,11 +55,23 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "@t3tools/contracts"; -import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { + TerminalManager, + type TerminalManagerShape, +} from "./terminal/Services/Manager"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "./persistence/Layers/Sqlite"; import { SqlClient, SqlError } from "effect/unstable/sql"; -import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; +import { + ProviderService, + type ProviderServiceShape, +} from "./provider/Services/ProviderService"; +import { + ProviderHealth, + type ProviderHealthShape, +} from "./provider/Services/ProviderHealth"; import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; @@ -55,7 +81,8 @@ import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.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); @@ -142,7 +169,9 @@ class MockTerminalManager implements TerminalManagerShape { }); }); - readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; + readonly resize: TerminalManagerShape["resize"] = ( + _input: TerminalResizeInput, + ) => Effect.void; readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => Effect.sync(() => { @@ -157,7 +186,9 @@ class MockTerminalManager implements TerminalManagerShape { }); }); - readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => + readonly restart: TerminalManagerShape["restart"] = ( + input: TerminalOpenInput, + ) => Effect.sync(() => { const now = new Date().toISOString(); const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; @@ -259,7 +290,11 @@ function dequeue(channel: MessageChannel, timeoutMs: number): Promise { timeoutId: setTimeout(() => { const index = channel.waiters.indexOf(waiter); if (index >= 0) channel.waiters.splice(index, 1); - reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); + reject( + new Error( + `Timed out waiting for WebSocket message after ${timeoutMs}ms`, + ), + ); }, timeoutMs) as ReturnType, }; channel.waiters.push(waiter); @@ -307,7 +342,11 @@ function connectWsOnce(port: number, token?: string): Promise { }); } -async function connectWs(port: number, token?: string, attempts = 5): Promise { +async function connectWs( + port: number, + token?: string, + attempts = 5, +): Promise { let lastError: unknown = new Error("WebSocket connection failed"); for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -383,14 +422,22 @@ async function rewriteKeybindingsAndWaitForPush( ws: WebSocket, keybindingsPath: string, contents: string, - predicate: (push: WsPushMessage) => boolean, + predicate: ( + push: WsPushMessage, + ) => boolean, attempts = 3, ): Promise> { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { fs.writeFileSync(keybindingsPath, contents, "utf8"); try { - return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000); + return await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + predicate, + 20, + 3_000, + ); } catch (error) { lastError = error; } @@ -428,26 +475,34 @@ async function requestPath( }); } -function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { +function compileKeybindings( + bindings: KeybindingsConfig, +): ResolvedKeybindingsConfig { const resolved: Array = []; for (const binding of bindings) { const compiled = compileResolvedKeybindingRule(binding); if (!compiled) { - throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); + throw new Error( + `Unexpected invalid keybinding in test setup: ${binding.command}`, + ); } resolved.push(compiled); } return resolved; } -const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); +const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([ + ...DEFAULT_KEYBINDINGS, +]); const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); function expectAvailableEditors(value: unknown): void { expect(Array.isArray(value)).toBe(true); for (const editorId of value as unknown[]) { expect(typeof editorId).toBe("string"); - expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true); + expect( + VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"]), + ).toBe(true); } } @@ -480,7 +535,10 @@ describe("WebSocket Server", () => { providerHealth?: ProviderHealthShape; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Pick< + GitCoreShape, + "listBranches" | "initRepo" | "pullCurrentBranch" + >; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -490,7 +548,8 @@ describe("WebSocket Server", () => { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); const scope = await Effect.runPromise(Scope.make("sequential")); - const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; + const persistenceLayer = + options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); const providerHealthLayer = Layer.succeed( ProviderHealth, @@ -511,9 +570,13 @@ describe("WebSocket Server", () => { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), } satisfies ServerConfigShape); - const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const infrastructureLayer = providerLayer.pipe( + Layer.provideMerge(persistenceLayer), + ); const runtimeOverrides = Layer.mergeAll( - options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, + options.gitManager + ? Layer.succeed(GitManager, options.gitManager) + : Layer.empty, options.gitCore ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) : Layer.empty, @@ -524,7 +587,9 @@ describe("WebSocket Server", () => { const runtimeLayer = Layer.merge( Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), + makeServerRuntimeServicesLayer().pipe( + Layer.provide(infrastructureLayer), + ), infrastructureLayer, ), runtimeOverrides, @@ -543,7 +608,10 @@ describe("WebSocket Server", () => { try { const runtime = await Effect.runPromise( - createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), + createServer().pipe( + Effect.provide(runtimeServices), + Scope.provide(scope), + ), ); serverScope = scope; return runtime; @@ -591,7 +659,13 @@ describe("WebSocket Server", () => { it("serves persisted attachments from stateDir", async () => { const stateDir = makeTempDir("t3code-state-attachments-"); - const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); + const attachmentPath = path.join( + stateDir, + "attachments", + "thread-a", + "message-a", + "0.png", + ); fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); @@ -600,7 +674,9 @@ describe("WebSocket Server", () => { const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); - const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); + const response = await fetch( + `http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`, + ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("image/png"); const bytes = Buffer.from(await response.arrayBuffer()); @@ -636,9 +712,17 @@ describe("WebSocket Server", () => { it("serves static index for root path", async () => { const stateDir = makeTempDir("t3code-state-static-root-"); const staticDir = makeTempDir("t3code-static-root-"); - fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); + fs.writeFileSync( + path.join(staticDir, "index.html"), + "

static-root

", + "utf8", + ); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -651,9 +735,17 @@ describe("WebSocket Server", () => { it("rejects static path traversal attempts", async () => { const stateDir = makeTempDir("t3code-state-static-traversal-"); const staticDir = makeTempDir("t3code-static-traversal-"); - fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); + fs.writeFileSync( + path.join(staticDir, "index.html"), + "

safe

", + "utf8", + ); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -683,7 +775,10 @@ describe("WebSocket Server", () => { }), ); - const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); + const snapshotResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.getSnapshot, + ); expect(snapshotResponse.error).toBeUndefined(); const snapshot = snapshotResponse.result as { projects: Array<{ @@ -701,8 +796,10 @@ describe("WebSocket Server", () => { worktreePath: string | null; }>; }; - const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; - const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; + const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }) + .bootstrapProjectId; + const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }) + .bootstrapThreadId; expect(bootstrapProjectId).toBeDefined(); expect(bootstrapThreadId).toBeDefined(); @@ -732,9 +829,9 @@ describe("WebSocket Server", () => { it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); - const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( - Layer.provide(NodeServices.layer), - ); + const persistenceLayer = makeSqlitePersistenceLive( + path.join(stateDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); const cwd = "/test/bootstrap-existing"; server = await createTestServer({ @@ -749,10 +846,12 @@ describe("WebSocket Server", () => { const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port); connections.push(firstWs); - const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) - .bootstrapProjectId; - const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) - .bootstrapThreadId; + const firstBootstrapProjectId = ( + firstWelcome.data as { bootstrapProjectId?: string } + ).bootstrapProjectId; + const firstBootstrapThreadId = ( + firstWelcome.data as { bootstrapThreadId?: string } + ).bootstrapThreadId; expect(firstBootstrapProjectId).toBeDefined(); expect(firstBootstrapThreadId).toBeDefined(); @@ -832,7 +931,9 @@ describe("WebSocket Server", () => { providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableEditors( + (response.result as { availableEditors: unknown }).availableEditors, + ); }); it("bootstraps default keybindings file when missing", async () => { @@ -857,7 +958,9 @@ describe("WebSocket Server", () => { providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableEditors( + (response.result as { availableEditors: unknown }).availableEditors, + ); const persistedConfig = JSON.parse( fs.readFileSync(keybindingsPath, "utf8"), @@ -892,7 +995,9 @@ describe("WebSocket Server", () => { providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableEditors( + (response.result as { availableEditors: unknown }).availableEditors, + ); expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); }); @@ -940,9 +1045,15 @@ describe("WebSocket Server", () => { message: expect.any(String), }, ]); - expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); - expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); - expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); + expect(result.keybindings).toHaveLength( + DEFAULT_RESOLVED_KEYBINDINGS.length, + ); + expect( + result.keybindings.some((entry) => entry.command === "terminal.toggle"), + ).toBe(true); + expect( + result.keybindings.some((entry) => entry.command === "terminal.new"), + ).toBe(true); expect(result.providers).toEqual(defaultProviderStatuses); expectAvailableEditors(result.availableEditors); }); @@ -969,7 +1080,9 @@ describe("WebSocket Server", () => { push.data.issues[0]!.kind === "keybindings.malformed-config", ); expect(malformedPush.data).toEqual({ - issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + issues: [ + { kind: "keybindings.malformed-config", message: expect.any(String) }, + ], providers: defaultProviderStatuses, }); @@ -977,9 +1090,13 @@ describe("WebSocket Server", () => { ws, keybindingsPath, "[]", - (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, + (push) => + Array.isArray(push.data.issues) && push.data.issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ + issues: [], + providers: defaultProviderStatuses, + }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -992,7 +1109,10 @@ describe("WebSocket Server", () => { }, }; - server = await createTestServer({ cwd: "/my/workspace", open: openService }); + server = await createTestServer({ + cwd: "/my/workspace", + open: openService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1039,7 +1159,9 @@ describe("WebSocket Server", () => { providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); - expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableEditors( + (response.result as { availableEditors: unknown }).availableEditors, + ); }); it("upserts keybinding rules and updates cached server config", async () => { @@ -1058,15 +1180,21 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); + const upsertResponse = await sendRequest( + ws, + WS_METHODS.serverUpsertKeybinding, + { + key: "mod+shift+r", + command: "script.run-tests.run", + }, + ); expect(upsertResponse.error).toBeUndefined(); const persistedConfig = JSON.parse( fs.readFileSync(keybindingsPath, "utf8"), ) as KeybindingsConfig; - const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); + const persistedCommands = new Set( + persistedConfig.map((entry) => entry.command), + ); for (const defaultRule of DEFAULT_KEYBINDINGS) { expect(persistedCommands.has(defaultRule.command)).toBe(true); } @@ -1112,13 +1240,19 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-missing", - fromTurnCount: 1, - toTurnCount: 2, - }); + const response = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.getTurnDiff, + { + threadId: "thread-missing", + fromTurnCount: 1, + toTurnCount: 2, + }, + ); expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("Thread 'thread-missing' not found."); + expect(response.error?.message).toContain( + "Thread 'thread-missing' not found.", + ); }); it("returns error when requesting turn diff with an inverted range", async () => { @@ -1129,11 +1263,15 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-any", - fromTurnCount: 2, - toTurnCount: 1, - }); + const response = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.getTurnDiff, + { + threadId: "thread-any", + fromTurnCount: 2, + toTurnCount: 1, + }, + ); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain( "fromTurnCount must be less than or equal to toTurnCount", @@ -1148,12 +1286,18 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { - threadId: "thread-missing", - toTurnCount: 2, - }); + const response = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + { + threadId: "thread-missing", + toTurnCount: 2, + }, + ); expect(response.result).toBeUndefined(); - expect(response.error?.message).toContain("Thread 'thread-missing' not found."); + expect(response.error?.message).toContain( + "Thread 'thread-missing' not found.", + ); }); it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { @@ -1166,46 +1310,61 @@ describe("WebSocket Server", () => { const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); const createdAt = new Date().toISOString(); - const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "project.create", - commandId: "cmd-diff-project-create", - projectId: "project-diff", - title: "Diff Project", - workspaceRoot, - defaultModel: "gpt-5-codex", - createdAt, - }); + const createProjectResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + type: "project.create", + commandId: "cmd-diff-project-create", + projectId: "project-diff", + title: "Diff Project", + workspaceRoot, + defaultModel: "gpt-5-codex", + createdAt, + }, + ); expect(createProjectResponse.error).toBeUndefined(); - const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.create", - commandId: "cmd-diff-thread-create", - threadId: "thread-diff", - projectId: "project-diff", - title: "Diff Thread", - model: "gpt-5-codex", - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, - }); + const createThreadResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + type: "thread.create", + commandId: "cmd-diff-thread-create", + threadId: "thread-diff", + projectId: "project-diff", + title: "Diff Thread", + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + ); expect(createThreadResponse.error).toBeUndefined(); - const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { - threadId: "thread-diff", - fromTurnCount: 0, - toTurnCount: 1, - }); + const response = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.getTurnDiff, + { + threadId: "thread-diff", + fromTurnCount: 0, + toTurnCount: 1, + }, + ); expect(response.result).toBeUndefined(); expect(response.error?.message).toContain("exceeds current turn count"); }); it("keeps orchestration domain push behavior for provider runtime events", async () => { - const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const runtimeEventPubSub = Effect.runSync( + PubSub.unbounded(), + ); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; - const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const unsupported = () => + Effect.die(new Error("Unsupported provider call in test")) as never; const providerService: ProviderServiceShape = { startSession: (threadId) => Effect.succeed({ @@ -1225,8 +1384,10 @@ describe("WebSocket Server", () => { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + stopAll: () => Effect.void, listSessions: () => Effect.succeed([]), - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getCapabilities: () => + Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -1244,46 +1405,58 @@ describe("WebSocket Server", () => { const workspaceRoot = makeTempDir("t3code-ws-project-"); const createdAt = new Date().toISOString(); - const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "project.create", - commandId: "cmd-ws-project-create", - projectId: "project-1", - title: "WS Project", - workspaceRoot, - defaultModel: "gpt-5-codex", - createdAt, - }); + const createProjectResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + type: "project.create", + commandId: "cmd-ws-project-create", + projectId: "project-1", + title: "WS Project", + workspaceRoot, + defaultModel: "gpt-5-codex", + createdAt, + }, + ); expect(createProjectResponse.error).toBeUndefined(); - const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.create", - commandId: "cmd-ws-runtime-thread-create", - threadId: "thread-1", - projectId: "project-1", - title: "Thread 1", - model: "gpt-5-codex", - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, - }); + const createThreadResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + type: "thread.create", + commandId: "cmd-ws-runtime-thread-create", + threadId: "thread-1", + projectId: "project-1", + title: "Thread 1", + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + ); expect(createThreadResponse.error).toBeUndefined(); - const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { - type: "thread.turn.start", - commandId: "cmd-ws-runtime-turn-start", - threadId: "thread-1", - message: { - messageId: "msg-ws-runtime-1", - role: "user", - text: "hello", - attachments: [], + const startTurnResponse = await sendRequest( + ws, + ORCHESTRATION_WS_METHODS.dispatchCommand, + { + type: "thread.turn.start", + commandId: "cmd-ws-runtime-turn-start", + threadId: "thread-1", + message: { + messageId: "msg-ws-runtime-1", + role: "user", + text: "hello", + attachments: [], + }, + assistantDeliveryMode: "streaming", + runtimeMode: "approval-required", + interactionMode: "default", + createdAt, }, - assistantDeliveryMode: "streaming", - runtimeMode: "approval-required", - interactionMode: "default", - createdAt, - }); + ); expect(startTurnResponse.error).toBeUndefined(); await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { @@ -1305,12 +1478,20 @@ describe("WebSocket Server", () => { }, } 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 } }; - return ( - event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" - ); - }); + const domainPush = await waitForPush( + ws, + ORCHESTRATION_WS_CHANNELS.domainEvent, + (push) => { + const event = push.data as { + type?: string; + payload?: { messageId?: string; text?: string }; + }; + return ( + event.type === "thread.message-sent" && + event.payload?.messageId === "assistant:item-1" + ); + }, + ); const domainEvent = domainPush.data as { type: string; @@ -1342,7 +1523,9 @@ describe("WebSocket Server", () => { }); expect(open.error).toBeUndefined(); expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); - expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); + expect((open.result as TerminalSessionSnapshot).terminalId).toBe( + DEFAULT_TERMINAL_ID, + ); const write = await sendRequest(ws, WS_METHODS.terminalWrite, { threadId: "thread-1", @@ -1466,11 +1649,16 @@ describe("WebSocket Server", () => { const brokenOpenService: OpenShape = { openBrowser: () => Effect.void, openInEditor: () => - Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), + Effect.sync(() => BigInt(1)).pipe( + Effect.map((result) => result as unknown as void), + ), }; try { - server = await createTestServer({ cwd: "/test", open: brokenOpenService }); + server = await createTestServer({ + cwd: "/test", + open: brokenOpenService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1541,7 +1729,9 @@ describe("WebSocket Server", () => { it("supports projects.searchEntries", async () => { const workspace = makeTempDir("t3code-ws-workspace-entries-"); - fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "src", "components"), { + recursive: true, + }); fs.writeFileSync( path.join(workspace, "src", "components", "Composer.tsx"), "export {};", @@ -1549,7 +1739,11 @@ describe("WebSocket Server", () => { ); fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); - fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + fs.writeFileSync( + path.join(workspace, ".git", "HEAD"), + "ref: refs/heads/main\n", + "utf8", + ); server = await createTestServer({ cwd: "/test" }); const addr = server.address(); @@ -1567,7 +1761,10 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ entries: expect.arrayContaining([ expect.objectContaining({ path: "src/components", kind: "directory" }), - expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), + expect.objectContaining({ + path: "src/components/Composer.tsx", + kind: "file", + }), ]), truncated: false, }); @@ -1593,9 +1790,9 @@ describe("WebSocket Server", () => { 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", - ); + 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 () => { @@ -1655,16 +1852,26 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); + const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { + cwd: "/repo/path", + }); expect(listResponse.error).toBeUndefined(); - expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); + expect(listResponse.result).toEqual({ + branches: [], + isRepo: false, + hasOriginRemote: false, + }); expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { + cwd: "/repo/path", + }); expect(initResponse.error).toBeUndefined(); expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); + const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { + cwd: "/repo/path", + }); expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); @@ -1731,7 +1938,9 @@ describe("WebSocket Server", () => { const gitManager: GitManagerShape = { status: vi.fn(() => Effect.void as any), resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), - preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), + preparePullRequestThread: vi.fn(() => + Effect.succeed(preparePullRequestThreadResult), + ), runStackedAction: vi.fn(() => Effect.void as any), }; @@ -1742,18 +1951,26 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { - cwd: "/test", - reference: "#42", - }); + const resolveResponse = await sendRequest( + ws, + WS_METHODS.gitResolvePullRequest, + { + cwd: "/test", + reference: "#42", + }, + ); expect(resolveResponse.error).toBeUndefined(); expect(resolveResponse.result).toEqual(resolvePullRequestResult); - const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { - cwd: "/test", - reference: "42", - mode: "worktree", - }); + const prepareResponse = await sendRequest( + ws, + WS_METHODS.gitPreparePullRequestThread, + { + cwd: "/test", + reference: "42", + mode: "worktree", + }, + ); expect(prepareResponse.error).toBeUndefined(); expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ @@ -1803,11 +2020,16 @@ describe("WebSocket Server", () => { }); it("rejects websocket connections without a valid auth token", async () => { - server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + server = await createTestServer({ + cwd: "/test", + authToken: "secret-token", + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; - await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); + await expect(connectWs(port)).rejects.toThrow( + "WebSocket connection failed", + ); const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); connections.push(authorizedWs); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaacf..8d31dcdaab 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -20,6 +20,17 @@ 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", () => { @@ -45,17 +56,31 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("keeps a saved custom provider model available as an exact slug option", () => { + const options = getAppModelOptions( + "claudeCode", + ["claude/custom-opus"], + "claude/custom-opus", + ); + + expect( + options.some( + (option) => option.slug === "claude/custom-opus" && option.isCustom, + ), + ).toBe(true); + }); }); describe("resolveAppModelSelection", () => { it("preserves saved custom model slugs instead of falling back to the default", () => { - expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe( - "galapagos-alpha", - ); + expect( + resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha"), + ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.3-codex"); }); }); @@ -63,4 +88,18 @@ describe("timestamp format defaults", () => { it("defaults timestamp format to locale", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); + + 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 18e76d2f92..45ad0b1c59 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,17 +1,32 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { + getDefaultModel, + getModelOptions, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; +export const TIMESTAMP_FORMAT_OPTIONS = [ + "locale", + "12-hour", + "24-hour", +] as const; export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { +const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record< + ProviderKind, + ReadonlySet +> = { 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({ @@ -24,9 +39,11 @@ const AppSettingsSchema = Schema.Struct({ defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + confirmThreadDelete: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(true)), + ), enableAssistantStreaming: Schema.Boolean.pipe( - Schema.withConstructorDefault(() => Option.some(false)), + Schema.withConstructorDefault(() => Option.some(true)), ), timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), @@ -34,6 +51,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 { @@ -78,11 +101,13 @@ export function getAppModelOptions( customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ - slug, - name, - isCustom: false, - })); + 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, provider)) { @@ -118,13 +143,16 @@ export function resolveAppModelSelection( const options = getAppModelOptions(provider, customModels, selectedModel); const trimmedSelectedModel = selectedModel?.trim(); if (trimmedSelectedModel) { - const direct = options.find((option) => option.slug === trimmedSelectedModel); + const direct = options.find( + (option) => option.slug === trimmedSelectedModel, + ); if (direct) { return direct.slug; } const byName = options.find( - (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), + (option) => + option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), ); if (byName) { return byName.slug; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e2904310..e3b6b04a09 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,14 +1,25 @@ -import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { + ProjectId, + type ProviderKind, + type ThreadId, +} from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadState, +} from "../composerDraftStore"; import { Schema } from "effect"; -export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = + "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; -export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export const LastInvokedScriptByProjectSchema = Schema.Record( + ProjectId, + Schema.String, +); export function buildLocalDraftThread( threadId: ThreadId, @@ -39,7 +50,11 @@ export function buildLocalDraftThread( } export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { + if ( + !previewUrl || + typeof URL === "undefined" || + !previewUrl.startsWith("blob:") + ) { return; } URL.revokeObjectURL(previewUrl); @@ -57,14 +72,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { } } -export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { +export function collectUserMessageBlobPreviewUrls( + message: ChatMessage, +): string[] { if (message.role !== "user" || !message.attachments) { return []; } const previewUrls: string[] = []; for (const attachment of message.attachments) { if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; + if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) + continue; previewUrls.push(attachment.previewUrl); } return previewUrls; @@ -116,10 +134,33 @@ export function cloneComposerImageForRetry( } } +export function getCustomModelsForProvider( + settings: { + customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; + }, + provider: ProviderKind, +): readonly string[] { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "cursor": + return settings.customCursorModels; + case "codex": + default: + return settings.customCodexModels; + } +} + export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), + cursor: getAppModelOptions("cursor", settings.customCursorModels), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e6..b21aaf5bd0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,15 +28,31 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { + gitBranchesQueryOptions, + gitCreateWorktreeMutationOptions, +} from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { + serverConfigQueryOptions, + serverQueryKeys, +} from "~/lib/serverReactQuery"; import { isElectron } from "../env"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { + parseDiffRouteSearch, + stripDiffSearchParams, +} from "../diffRouteSearch"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -86,7 +102,10 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { @@ -128,14 +147,29 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { + selectThreadTerminalState, + useTerminalStateStore, +} from "../terminalStateStore"; +import { + ComposerPromptEditor, + type ComposerPromptEditorHandle, +} from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; -import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; +import { + buildExpandedImagePreview, + ExpandedImagePreview, +} from "./chat/ExpandedImagePreview"; +import { + AVAILABLE_PROVIDER_OPTIONS, + ProviderModelPicker, +} from "./chat/ProviderModelPicker"; +import { + ComposerCommandItem, + ComposerCommandMenu, +} from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; @@ -149,6 +183,7 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + getCustomModelsForProvider, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -169,7 +204,10 @@ 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 EMPTY_PENDING_USER_INPUT_ANSWERS: Record< + string, + PendingUserInputDraftAnswer +> = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -205,36 +243,62 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); + const createWorktreeMutation = useMutation( + gitCreateWorktreeMutationOptions({ queryClient }), + ); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; 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 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); + 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, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const clearComposerDraftContent = useComposerDraftStore( + (store) => store.clearComposerContent, + ); + const setDraftThreadContext = useComposerDraftStore( + (store) => store.setDraftThreadContext, + ); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setProjectDraftThreadId = useComposerDraftStore( + (store) => store.setProjectDraftThreadId, + ); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -244,8 +308,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [expandedImage, setExpandedImage] = useState(null); - const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [expandedImage, setExpandedImage] = + useState(null); + const [optimisticUserMessages, setOptimisticUserMessages] = useState< + ChatMessage[] + >([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< @@ -255,16 +322,22 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState([]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< + const [respondingRequestIds, setRespondingRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = + useState([]); + const [ + pendingUserInputAnswersByRequestId, + setPendingUserInputAnswersByRequestId, + ] = useState>>({}); + const [ + pendingUserInputQuestionIndexByRequestId, + setPendingUserInputQuestionIndexByRequestId, + ] = useState>({}); + const [expandedWorkGroups, setExpandedWorkGroups] = useState< + Record >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. @@ -274,25 +347,31 @@ export default function ChatView({ threadId }: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState< + string | null + >(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); + const [ + attachmentPreviewHandoffByMessageId, + setAttachmentPreviewHandoffByMessageId, + ] = useState>({}); const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); + const [composerTrigger, setComposerTrigger] = + useState(() => + detectComposerTrigger(prompt, prompt.length), + ); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = + useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); + const [messagesScrollElement, setMessagesScrollElement] = + useState(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); @@ -312,24 +391,35 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); + const attachmentPreviewHandoffByMessageIdRef = useRef< + Record + >({}); + const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef< + Record + >({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); + const setMessagesScrollContainerRef = useCallback( + (element: HTMLDivElement | null) => { + messagesScrollRef.current = element; + setMessagesScrollElement(element); + }, + [], + ); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); + const storeSetTerminalHeight = useTerminalStateStore( + (s) => s.setTerminalHeight, + ); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); + const storeSetActiveTerminal = useTerminalStateStore( + (s) => s.setActiveTerminal, + ); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( @@ -358,8 +448,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const serverThread = threads.find((t) => t.id === threadId); - const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const fallbackDraftProject = projects.find( + (project) => project.id === draftThread?.projectId, + ); + const localDraftError = serverThread + ? null + : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread @@ -374,16 +468,23 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + composerDraft.runtimeMode ?? + activeThread?.runtimeMode ?? + DEFAULT_RUNTIME_MODE; const interactionMode = - composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + composerDraft.interactionMode ?? + activeThread?.interactionMode ?? + DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; - const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); + const latestTurnSettled = isLatestTurnSettled( + activeLatestTurn, + activeThread?.session ?? null, + ); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( @@ -405,14 +506,24 @@ export default function ChatView({ threadId }: ChatViewProps) { }, []); const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + async (input: { + branch: string; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + }) => { if (!activeProject) { - throw new Error("No active project is available for this pull request."); + throw new Error( + "No active project is available for this pull request.", + ); } const storedDraftThread = getDraftThreadByProjectId(activeProject.id); if (storedDraftThread) { setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + setProjectDraftThreadId( + activeProject.id, + storedDraftThread.threadId, + input, + ); if (storedDraftThread.threadId !== threadId) { await navigate({ to: "/$threadId", @@ -423,7 +534,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + if ( + !isServerThread && + activeDraftThread?.projectId === activeProject.id + ) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); return; @@ -472,8 +586,11 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; + const lastVisitedAt = activeThread.lastVisitedAt + ? Date.parse(activeThread.lastVisitedAt) + : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) + return; markThreadVisited(activeThread.id); }, [ @@ -495,12 +612,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + activeThread?.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider), + ); + const customModelsForSelectedProvider = getCustomModelsForProvider( + settings, + selectedProvider, ); - const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -511,10 +634,16 @@ export default function ChatView({ threadId }: ChatViewProps) { customModelsForSelectedProvider, draftModel, ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + }, [ + baseThreadModel, + composerDraft.model, + customModelsForSelectedProvider, + selectedProvider, + ]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedEffort = + composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; const selectedModelOptionsForDispatch = useMemo(() => { @@ -522,18 +651,29 @@ export default function ChatView({ threadId }: ChatViewProps) { return undefined; } const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), + ...(supportsReasoningEffort && selectedEffort + ? { reasoningEffort: selectedEffort } + : {}), ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + return Object.keys(codexOptions).length > 0 + ? { codex: codexOptions } + : undefined; + }, [ + selectedCodexFastModeEnabled, + selectedEffort, + selectedProvider, + supportsReasoningEffort, + ]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } return { codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexBinaryPath + ? { binaryPath: settings.codexBinaryPath } + : {}), ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; @@ -545,9 +685,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) + return currentOptions.some( + (option) => option.slug === selectedModelForPicker, + ) ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? + selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => @@ -569,7 +712,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -578,7 +722,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), + () => + deriveWorkLogEntries( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + ), [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo( @@ -597,13 +745,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) + ? (pendingUserInputAnswersByRequestId[ + activePendingUserInput.requestId + ] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, [activePendingUserInput, pendingUserInputAnswersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + ? (pendingUserInputQuestionIndexByRequestId[ + activePendingUserInput.requestId + ] ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -614,12 +765,19 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingQuestionIndex, ) : null, - [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], + [ + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingUserInput, + ], ); const activePendingResolvedAnswers = useMemo( () => activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + ? buildPendingUserInputAnswers( + activePendingUserInput.questions, + activePendingDraftAnswers, + ) : null, [activePendingDraftAnswers, activePendingUserInput], ); @@ -634,9 +792,17 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + }, [ + activeLatestTurn?.turnId, + activeThread?.proposedPlans, + latestTurnSettled, + ]); const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), + () => + deriveActivePlanState( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + ), [activeLatestTurn?.turnId, threadActivities], ); const showPlanFollowUpPrompt = @@ -650,7 +816,8 @@ export default function ChatView({ threadId }: ChatViewProps) { isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const composerFooterHasWideActions = + showPlanFollowUpPrompt || activePendingProgress !== null; const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -678,7 +845,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + const nextCursor = collapseExpandedComposerCursor( + nextCustomAnswer, + nextCustomAnswer.length, + ); setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( @@ -693,14 +863,19 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingProgress?.activeQuestion?.id, ]); useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; + attachmentPreviewHandoffByMessageIdRef.current = + attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { + for (const timeoutId of Object.values( + attachmentPreviewHandoffTimeoutByMessageIdRef.current, + )) { window.clearTimeout(timeoutId); } attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { + for (const previewUrls of Object.values( + attachmentPreviewHandoffByMessageIdRef.current, + )) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); } @@ -716,45 +891,54 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - for (const previewUrl of previousPreviewUrls) { - if (!previewUrls.includes(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - - const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { - const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { + const handoffAttachmentPreviews = useCallback( + (messageId: MessageId, previewUrls: string[]) => { + if (previewUrls.length === 0) return; + + const previousPreviewUrls = + attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + for (const previewUrl of previousPreviewUrls) { + if (!previewUrls.includes(previewUrl)) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; + const next = { + ...existing, + [messageId]: previewUrls, + }; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, []); + + const existingTimeout = + attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; + if (typeof existingTimeout === "number") { + window.clearTimeout(existingTimeout); + } + attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = + window.setTimeout(() => { + const currentPreviewUrls = + attachmentPreviewHandoffByMessageIdRef.current[messageId]; + if (currentPreviewUrls) { + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + } + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) return existing; + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[ + messageId + ]; + }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); + }, + [], + ); const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; @@ -773,7 +957,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return message; } - const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; + const handoffPreviewUrls = + attachmentPreviewHandoffByMessageId[message.id]; if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { return message; } @@ -786,7 +971,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; imageIndex += 1; - if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { + if ( + !handoffPreviewUrl || + attachment.previewUrl === handoffPreviewUrl + ) { return attachment; } changed = true; @@ -802,16 +990,28 @@ export default function ChatView({ threadId }: ChatViewProps) { if (optimisticUserMessages.length === 0) { return serverMessagesWithPreviewHandoff; } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); + const serverIds = new Set( + serverMessagesWithPreviewHandoff.map((message) => message.id), + ); + const pendingMessages = optimisticUserMessages.filter( + (message) => !serverIds.has(message.id), + ); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + ), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = @@ -832,7 +1032,11 @@ export default function ChatView({ threadId }: ChatViewProps) { continue; } - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + for ( + let nextIndex = index + 1; + nextIndex < timelineEntries.length; + nextIndex += 1 + ) { const nextEntry = timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; @@ -840,12 +1044,15 @@ export default function ChatView({ threadId }: ChatViewProps) { if (nextEntry.message.role === "user") { break; } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + const summary = turnDiffSummaryByAssistantMessageId.get( + nextEntry.message.id, + ); if (!summary) { continue; } const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof turnCount !== "number") { break; } @@ -855,7 +1062,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + }, [ + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; @@ -863,7 +1074,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); + const elapsed = formatElapsed( + activeLatestTurn.startedAt, + activeLatestTurn.completedAt, + ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, @@ -904,14 +1118,16 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const pathTriggerQuery = + composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const effectivePathQuery = + pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( @@ -922,7 +1138,8 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const workspaceEntries = + workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -959,13 +1176,16 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, - ] satisfies ReadonlyArray>; + ] satisfies ReadonlyArray< + Extract + >; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { return [...slashCommandItems]; } return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + (item) => + item.command.includes(query) || item.label.slice(1).includes(query), ); } @@ -974,7 +1194,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const query = composerTrigger.query.trim().toLowerCase(); if (!query) return true; return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + searchSlug.includes(query) || + searchName.includes(query) || + searchProvider.includes(query) ); }) .map(({ provider, providerLabel, slug, name }) => ({ @@ -1002,11 +1224,15 @@ export default function ChatView({ threadId }: ChatViewProps) { [nonPersistedComposerImageIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const availableEditors = + serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const providerStatuses = + serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProvider = activeThread?.session?.provider ?? "codex"; const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, + () => + providerStatuses.find((status) => status.provider === activeProvider) ?? + null, [activeProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; @@ -1053,7 +1279,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "closed")), + (activeThread.session !== null && + activeThread.session.status !== "closed")), ); const activeTerminalGroup = terminalState.terminalGroups.find( @@ -1187,8 +1414,10 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const isBaseTerminalBusy = + terminalState.runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = + Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` @@ -1206,24 +1435,26 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: + options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); - const openTerminalInput: Parameters[0] = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - }; + const openTerminalInput: Parameters[0] = + shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + }; try { await api.terminal.open(openTerminalInput); @@ -1235,7 +1466,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } catch (error) { setThreadError( activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + error instanceof Error + ? error.message + : `Failed to run script "${script.name}".`, ); } }, @@ -1303,7 +1536,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, + script.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, ), nextScript, ] @@ -1323,7 +1558,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const updateProjectScript = useCallback( async (scriptId: string, input: NewProjectScriptInput) => { if (!activeProject) return; - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProject.scripts.find( + (script) => script.id === scriptId, + ); if (!existingScript) { throw new Error("Script not found."); } @@ -1357,9 +1594,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const deleteProjectScript = useCallback( async (scriptId: string) => { if (!activeProject) return; - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProject.scripts.filter( + (script) => script.id !== scriptId, + ); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProject.scripts.find( + (s) => s.id === scriptId, + )?.name; try { await persistProjectScripts({ @@ -1378,7 +1619,10 @@ export default function ChatView({ threadId }: ChatViewProps) { toastManager.add({ type: "error", title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred.", }); } }, @@ -1423,7 +1667,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); const toggleInteractionMode = useCallback(() => { - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); + handleInteractionModeChange( + interactionMode === "plan" ? "default" : "plan", + ); }, [handleInteractionModeChange, interactionMode]); const toggleRuntimeMode = useCallback(() => { void handleRuntimeModeChange( @@ -1433,7 +1679,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1494,13 +1741,16 @@ export default function ChatView({ threadId }: ChatViewProps) { // Auto-scroll on new messages const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); + const scrollMessagesToBottom = useCallback( + (behavior: ScrollBehavior = "auto") => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + shouldAutoScrollRef.current = true; + }, + [], + ); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; @@ -1537,21 +1787,27 @@ export default function ChatView({ threadId }: ChatViewProps) { }; 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; - }); + 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], ); @@ -1559,7 +1815,11 @@ export default function ChatView({ threadId }: ChatViewProps) { cancelPendingStickToBottom(); scrollMessagesToBottom(); scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); + }, [ + cancelPendingStickToBottom, + scheduleStickToBottom, + scrollMessagesToBottom, + ]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; @@ -1569,13 +1829,19 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!shouldAutoScrollRef.current && isNearBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { + } else if ( + shouldAutoScrollRef.current && + pendingUserScrollUpIntentRef.current + ) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { + } else if ( + shouldAutoScrollRef.current && + isPointerScrollActiveRef.current + ) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; @@ -1591,37 +1857,58 @@ export default function ChatView({ threadId }: ChatViewProps) { setShowScrollToBottom(!shouldAutoScrollRef.current); lastKnownScrollTopRef.current = currentScrollTop; }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); + const onMessagesWheel = useCallback( + (event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, + [], + ); + const onMessagesPointerDown = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, + [], + ); + const onMessagesPointerUp = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, + [], + ); + const onMessagesPointerCancel = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, + [], + ); + const onMessagesTouchStart = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, + [], + ); + const onMessagesTouchMove = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, + [], + ); + const onMessagesTouchEnd = useCallback( + (_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; + }, + [], + ); useEffect(() => { return () => { cancelPendingStickToBottom(); @@ -1659,16 +1946,22 @@ export default function ChatView({ threadId }: ChatViewProps) { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); - setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); + const nextCompact = shouldUseCompactComposerFooter( + measureComposerFormWidth(), + { + hasWideActions: composerFooterHasWideActions, + }, + ); + setIsComposerFooterCompact((previous) => + previous === nextCompact ? previous : nextCompact, + ); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) + return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }); @@ -1735,8 +2028,12 @@ export default function ChatView({ threadId }: ChatViewProps) { if (activeThread.messages.length === 0) { return; } - const serverIds = new Set(activeThread.messages.map((message) => message.id)); - const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); + const serverIds = new Set( + activeThread.messages.map((message) => message.id), + ); + const removedMessages = optimisticUserMessages.filter((message) => + serverIds.has(message.id), + ); if (removedMessages.length === 0) { return; } @@ -1756,11 +2053,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { window.clearTimeout(timer); }; - }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); + }, [ + activeThread?.id, + activeThread?.messages, + handoffAttachmentPreviews, + optimisticUserMessages, + ]); useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); + setComposerCursor((existing) => + clampCollapsedComposerCursor(prompt, existing), + ); }, [prompt]); useEffect(() => { @@ -1773,8 +2077,15 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("idle"); setSendStartedAt(null); setComposerHighlightedItemId(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setComposerCursor( + collapseExpandedComposerCursor( + promptRef.current, + promptRef.current.length, + ), + ); + setComposerTrigger( + detectComposerTrigger(promptRef.current, promptRef.current.length), + ); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -1788,13 +2099,20 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + useComposerDraftStore.getState().draftsByThreadId[threadId] + ?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), + currentPersistedAttachments.map((attachment) => [ + attachment.id, + attachment, + ]), ); - const stagedAttachmentById = new Map(); + const stagedAttachmentById = new Map< + string, + PersistedComposerImageAttachment + >(); await Promise.all( composerImages.map(async (image) => { try { @@ -1821,14 +2139,16 @@ export default function ChatView({ threadId }: ChatViewProps) { // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); + const currentImageIds = new Set( + composerImages.map((image) => image.id), + ); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => - fallbackPersistedIdSet.has(attachment.id), + const fallbackAttachments = fallbackPersistedAttachments.filter( + (attachment) => fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) { return; @@ -1855,7 +2175,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return existing; } const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; + (existing.index + direction + existing.images.length) % + existing.images.length; if (nextIndex === existing.index) { return existing; } @@ -1911,10 +2232,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase]); - const beginSendPhase = useCallback((nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, []); + const beginSendPhase = useCallback( + (nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + setSendPhase(nextPhase); + }, + [], + ); const resetSendPhase = useCallback(() => { setSendPhase("idle"); @@ -2021,7 +2345,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProject.scripts.find( + (entry) => entry.id === scriptId, + ); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -2134,7 +2460,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } event.preventDefault(); const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + if ( + nextTarget instanceof Node && + event.currentTarget.contains(nextTarget) + ) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); @@ -2161,7 +2490,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!api || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + setThreadError( + activeThread.id, + "Interrupt the current turn before reverting checkpoints.", + ); return; } const confirmed = await api.dialogs.confirm( @@ -2193,13 +2525,27 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [ + activeThread, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if ( + !api || + !activeThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) + return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2222,7 +2568,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + composerImages.length === 0 + ? parseStandaloneComposerSlashCommand(trimmed) + : null; if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2235,7 +2583,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeThread.messages.length === 0; + const isFirstMessage = + !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch @@ -2254,7 +2603,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); + beginSendPhase( + baseBranchForWorktree ? "preparing-worktree" : "sending-turn", + ); const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); @@ -2282,7 +2633,9 @@ export default function ChatView({ threadId }: ChatViewProps) { id: messageIdForSend, role: "user", text: trimmed, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + ...(optimisticAttachments.length > 0 + ? { attachments: optimisticAttachments } + : {}), createdAt: messageCreatedAt, streaming: false, }, @@ -2324,7 +2677,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + setStoreThreadBranch( + threadIdForSend, + result.worktree.branch, + result.worktree.path, + ); } } @@ -2345,7 +2702,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || + (activeProject.model as ModelSlug) || + DEFAULT_MODEL_BY_PROVIDER.codex; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2426,9 +2785,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2450,17 +2813,25 @@ export default function ChatView({ threadId }: ChatViewProps) { composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); + const removed = existing.filter( + (message) => message.id === messageIdForSend, + ); for (const message of removed) { revokeUserMessagePreviewUrls(message); } - const next = existing.filter((message) => message.id !== messageIdForSend); + const next = existing.filter( + (message) => message.id !== messageIdForSend, + ); return next.length === existing.length ? existing : next; }); promptRef.current = trimmed; setPrompt(trimmed); - setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + setComposerCursor( + collapseExpandedComposerCursor(trimmed, trimmed.length), + ); + addComposerImagesToDraft( + composerImagesSnapshot.map(cloneComposerImageForRetry), + ); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( @@ -2486,7 +2857,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onRespondToApproval = useCallback( - async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + async ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => { const api = readNativeApi(); if (!api || !activeThreadId) return; @@ -2505,10 +2879,14 @@ export default function ChatView({ threadId }: ChatViewProps) { .catch((err: unknown) => { setStoreThreadError( activeThreadId, - err instanceof Error ? err.message : "Failed to submit approval decision.", + err instanceof Error + ? err.message + : "Failed to submit approval decision.", ); }); - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + setRespondingRequestIds((existing) => + existing.filter((id) => id !== requestId), + ); }, [activeThreadId, setStoreThreadError], ); @@ -2536,7 +2914,9 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to submit user input.", ); }); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + setRespondingUserInputRequestIds((existing) => + existing.filter((id) => id !== requestId), + ); }, [activeThreadId, setStoreThreadError], ); @@ -2600,7 +2980,9 @@ export default function ChatView({ threadId }: ChatViewProps) { })); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTrigger(value, expandedCursor), ); }, [activePendingUserInput], @@ -2612,11 +2994,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { - void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + void onRespondToUserInput( + activePendingUserInput.requestId, + activePendingResolvedAnswers, + ); } return; } - setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + setActivePendingUserInputQuestionIndex( + activePendingProgress.questionIndex + 1, + ); }, [ activePendingProgress, activePendingResolvedAnswers, @@ -2629,7 +3016,9 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingProgress) { return; } - setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + setActivePendingUserInputQuestionIndex( + Math.max(activePendingProgress.questionIndex - 1, 0), + ); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); const onSubmitPlanFollowUp = useCallback( @@ -2705,8 +3094,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, @@ -2770,7 +3163,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadTitle = truncateTitle( + buildPlanImplementationThreadTitle(planMarkdown), + ); const nextThreadModel: ModelSlug = selectedModel || (activeThread.model as ModelSlug) || @@ -2814,8 +3209,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -2849,7 +3248,9 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "error", title: "Could not start implementation thread", description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", + err instanceof Error + ? err.message + : "An error occurred while creating the new thread.", }); }) .then(finish, finish); @@ -2882,7 +3283,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection( + provider, + getCustomModelsForProvider(settings, provider), + model, + ), ); scheduleComposerFocus(); }, @@ -2916,7 +3321,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [ + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + threadId, + ], ); const applyPromptReplacement = useCallback( @@ -2928,14 +3338,22 @@ export default function ChatView({ threadId }: ChatViewProps) { ): 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)); + 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); + const next = replaceTextRange( + promptRef.current, + rangeStart, + rangeEnd, + replacement, + ); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; @@ -2945,7 +3363,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], + existing[activePendingUserInput.requestId]?.[ + activePendingQuestion.id + ], next.text, ), }, @@ -2955,7 +3375,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } setComposerCursor(nextCursor); setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + detectComposerTrigger( + next.text, + expandCollapsedComposerCursor(next.text, nextCursor), + ), ); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); @@ -2977,7 +3400,10 @@ export default function ChatView({ threadId }: ChatViewProps) { return { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + expandedCursor: expandCollapsedComposerCursor( + promptRef.current, + composerCursor, + ), }; }, [composerCursor]); @@ -3012,7 +3438,12 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice( + trigger.rangeStart, + replacementRangeEnd, + ), + }, ); if (applied) { setComposerHighlightedItemId(null); @@ -3031,26 +3462,49 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice( + trigger.rangeStart, + replacementRangeEnd, + ), + }, ); if (applied) { setComposerHighlightedItemId(null); } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); + void handleInteractionModeChange( + item.command === "plan" ? "plan" : "default", + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + "", + { + expectedText: snapshot.value.slice( + trigger.rangeStart, + trigger.rangeEnd, + ), + }, + ); if (applied) { setComposerHighlightedItemId(null); } return; } onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + "", + { + expectedText: snapshot.value.slice( + trigger.rangeStart, + trigger.rangeEnd, + ), + }, + ); if (applied) { setComposerHighlightedItemId(null); } @@ -3077,7 +3531,8 @@ export default function ChatView({ threadId }: ChatViewProps) { highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; const offset = key === "ArrowDown" ? 1 : -1; const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + (normalizedIndex + offset + composerMenuItems.length) % + composerMenuItems.length; const nextItem = composerMenuItems[nextIndex]; setComposerHighlightedItemId(nextItem?.id ?? null); }, @@ -3085,7 +3540,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const isComposerMenuLoading = composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + ((pathTriggerQuery.length > 0 && + composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); @@ -3110,7 +3566,9 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ @@ -3144,7 +3602,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return true; } if (key === "Tab" || key === "Enter") { - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + const selectedItem = + activeComposerMenuItemRef.current ?? currentItems[0]; if (selectedItem) { onSelectComposerItem(selectedItem); return true; @@ -3167,7 +3626,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); - const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; + const expandedImageItem = expandedImage + ? expandedImage.images[expandedImage.index] + : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ @@ -3199,18 +3660,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
- Threads + + Threads +
)} {isElectron && (
- No active thread + + No active thread +
)}
-

Select a thread or create a new one to get started.

+

+ Select a thread or create a new one to get started. +

@@ -3223,7 +3690,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Chat column */}
- {/* Messages Wrapper */} -
- {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={activeProject?.cwd ?? undefined} - /> -
- - {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- -
- )} + {/* Messages */} +
+ 0} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} + scrollContainer={messagesScrollElement} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={ + turnDiffSummaryByAssistantMessageId + } + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={activeProject?.cwd ?? undefined} + />
{/* Input bar */} -
+
) : null} @@ -3435,8 +3901,8 @@ export default function ChatView({ threadId }: ChatViewProps) { side="top" className="max-w-64 whitespace-normal leading-tight" > - Draft attachment could not be saved locally and may be lost on - navigation. + Draft attachment could not be saved locally + and may be lost on navigation. )} @@ -3487,7 +3953,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
@@ -3521,13 +3989,17 @@ export default function ChatView({ threadId }: ChatViewProps) { {isComposerFooterCompact ? ( ) : ( <> - {selectedProvider === "codex" && selectedEffort != null ? ( + {selectedProvider === "codex" && + selectedEffort != null ? ( <> void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", + runtimeMode === "full-access" + ? "approval-required" + : "full-access", ) } title={ @@ -3597,13 +4072,21 @@ export default function ChatView({ threadId }: ChatViewProps) { : "Approval required — click for full access" } > - {runtimeMode === "full-access" ? : } + {runtimeMode === "full-access" ? ( + + ) : ( + + )} - {runtimeMode === "full-access" ? "Full access" : "Supervised"} + {runtimeMode === "full-access" + ? "Full access" + : "Supervised"} - {activePlan || activeProposedPlan || planSidebarOpen ? ( + {activePlan || + activeProposedPlan || + planSidebarOpen ? ( <> - Plan + + Plan + ) : null} @@ -3698,7 +4187,9 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-full px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + {isConnecting || isSendBusy + ? "Sending..." + : "Refine"} ) : (
@@ -3708,7 +4199,9 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isConnecting || isSendBusy + ? "Sending..." + : "Implement"} void onImplementPlanInNewThread()} + onClick={() => + void onImplementPlanInNewThread() + } > Implement in new thread @@ -3839,7 +4334,8 @@ export default function ChatView({ threadId }: ChatViewProps) { onClose={() => { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9bc034991e..e31cfc8aee 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,15 +17,24 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + Gemini, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { cn } from "~/lib/utils"; -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { +function isAvailableProviderOption( + option: (typeof PROVIDER_OPTIONS)[number], +): option is { value: ProviderKind; label: string; available: true; } { - return option.available && option.value !== "claudeCode"; + return option.available; } function resolveModelForProviderPicker( @@ -43,7 +52,9 @@ function resolveModelForProviderPicker( return direct.slug; } - const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); + const byName = options.find( + (option) => option.name.toLowerCase() === trimmedValue.toLowerCase(), + ); if (byName) { return byName.slug; } @@ -67,8 +78,12 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { cursor: CursorIcon, }; -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +export 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 }, @@ -78,7 +93,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; - modelOptionsByProvider: Record>; + modelOptionsByProvider: Record< + ProviderKind, + ReadonlyArray<{ slug: string; name: string }> + >; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -86,7 +104,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const [isMenuOpen, setIsMenuOpen] = useState(false); const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + selectedProviderOptions.find((option) => option.slug === props.model) + ?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; return ( @@ -114,9 +133,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { } > - @@ -125,7 +150,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {AVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const isDisabledByProviderLock = - props.lockedProvider !== null && props.lockedProvider !== option.value; + props.lockedProvider !== null && + props.lockedProvider !== option.value; return ( @@ -153,15 +179,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(false); }} > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} + {props.modelOptionsByProvider[option.value].map( + (modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ), + )} @@ -177,7 +205,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { aria-hidden="true" className={cn( "size-4 shrink-0 opacity-80", - option.value === "claudeCode" ? "" : "text-muted-foreground/85", + option.value === "claudeCode" + ? "" + : "text-muted-foreground/85", )} /> {option.label} @@ -192,7 +222,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const OptionIcon = option.icon; return ( -