authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-19 19:54:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-20 02:54:21+00:00
log77716b4ccb529b635730e30a4ca94f5affe9db24
tree54a05359a778c6f0afcf59865c3ef0f42285102e
parentfb18b2de16195623427ec11b0c06e3245e7a8903
signature Signed by PGP key B5690EEEBB952194

feat: add Claude Code adapter (#179)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

74 files changed, 12574 insertions(+), 1240 deletions(-)

.plans/17-claude-agent.md created+441
......@@ -0,0 +1,441 @@
1# Plan: Claude Code Integration (Orchestration Architecture)
2
3## Why this plan was rewritten
4
5The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through:
6
71. `orchestration.dispatchCommand` (client intent)
82. `OrchestrationEngine` (decide + persist + publish domain events)
93. `ProviderCommandReactor` (domain intent -> `ProviderService`)
104. `ProviderService` (adapter routing + canonical runtime stream)
115. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands)
126. `orchestration.domainEvent` (single push channel consumed by web)
13
14Claude integration must plug into this path instead of reintroducing legacy provider-specific flows.
15
16---
17
18## Current constraints to design around (post-Stage 1)
19
201. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads.
212. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed.
223. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`.
234. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`.
245. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior.
256. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only.
26
27---
28
29## Architecture target
30
31Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths.
32
33Key decisions:
34
351. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries.
362. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract.
373. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`.
384. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally.
39
40---
41
42## Phase 1: Contracts and command shape updates
43
44### 1.1 Provider-aware model contract
45
46Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only.
47
48Expected outcomes:
49
501. Introduce provider-scoped model lists (Codex + Claude).
512. Add helpers that resolve model by provider.
523. Preserve backwards compatibility for existing Codex defaults.
53
54### 1.2 Turn-start provider intent
55
56Update `packages/contracts/src/orchestration.ts`:
57
581. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`.
592. Carry provider through `ThreadTurnStartRequestedPayload`.
603. Keep existing command valid when provider is omitted.
61
62This removes the implicit “Codex unless session already exists” behavior as the only path.
63
64### 1.3 Provider session start input for Claude runtime knobs (completed)
65
66Update `packages/contracts/src/provider.ts`:
67
681. Move provider-specific start fields into typed `providerOptions`:
69 - `providerOptions.codex`
70 - `providerOptions.claudeCode`
712. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`.
723. Deprecate/remove `resumeThreadId` from the generic start contract.
734. Treat `resumeCursor` as adapter-owned opaque state.
74
75### 1.4 Contract tests (completed)
76
77Update/add tests in `packages/contracts/src/*.test.ts` for:
78
791. New command payload shape.
802. Provider-aware model resolution behavior.
813. Breaking-change expectations for removed top-level provider fields.
82
83---
84
85## Phase 2: Claude adapter implementation
86
87### 2.1 Add adapter service + layer
88
89Create:
90
911. `apps/server/src/provider/Services/ClaudeAdapter.ts`
922. `apps/server/src/provider/Layers/ClaudeAdapter.ts`
93
94Adapter must implement `ProviderAdapterShape<ProviderAdapterError>`.
95
96### 2.1.a SDK dependency and baseline config
97
98Add server dependency:
99
1001. `@anthropic-ai/claude-agent-sdk`
101
102Baseline adapter options to support from day one:
103
1041. `cwd`
1052. `model`
1063. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`)
1074. `permissionMode` (from `providerOptions.claudeCode.permissionMode`)
1085. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`)
1096. `resume`
1107. `resumeSessionAt`
1118. `includePartialMessages`
1129. `canUseTool`
11310. `hooks`
11411. `env` and `additionalDirectories` (if needed for sandbox/workspace parity)
115
116### 2.2 Claude runtime bridge
117
118Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle.
119
120Required capabilities:
121
1221. Long-lived session context per adapter session.
1232. Multi-turn input queue.
1243. Interrupt support.
1254. Approval request/response bridge.
1265. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only).
127
128#### 2.2.a Agent SDK details to preserve
129
130The adapter should explicitly rely on these SDK capabilities:
131
1321. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers).
1332. Multi-turn input is supported via async-iterable prompt input.
1343. Tool approval decisions are provided via `canUseTool`.
1354. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`.
1365. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events.
137
138#### 2.2.b Effect-native session lifecycle skeleton
139
140```ts
141import { query } from "@anthropic-ai/claude-agent-sdk";
142import { Effect } from "effect";
143
144const acquireSession = (input: ProviderSessionStartInput) =>
145 Effect.acquireRelease(
146 Effect.tryPromise({
147 try: async () => {
148 const claudeOptions = input.providerOptions?.claudeCode;
149 const resumeState = readClaudeResumeState(input.resumeCursor);
150 const abortController = new AbortController();
151 const result = query({
152 prompt: makePromptAsyncIterable(),
153 options: {
154 cwd: input.cwd,
155 model: input.model,
156 permissionMode: claudeOptions?.permissionMode,
157 maxThinkingTokens: claudeOptions?.maxThinkingTokens,
158 pathToClaudeCodeExecutable: claudeOptions?.binaryPath,
159 resume: resumeState?.threadId,
160 resumeSessionAt: resumeState?.sessionAt,
161 signal: abortController.signal,
162 includePartialMessages: true,
163 canUseTool: makeCanUseTool(),
164 hooks: makeClaudeHooks(),
165 },
166 });
167 return { abortController, result };
168 },
169 catch: (cause) =>
170 new ProviderAdapterProcessError({
171 provider: "claudeCode",
172 sessionId: "pending",
173 detail: "Failed to start Claude runtime session.",
174 cause,
175 }),
176 }),
177 ({ abortController }) => Effect.sync(() => abortController.abort()),
178 );
179```
180
181#### 2.2.c AsyncIterable -> Effect Stream integration
182
183Preferred when available in the pinned Effect version:
184
185```ts
186const sdkMessageStream = Stream.fromAsyncIterable(
187 session.result,
188 (cause) =>
189 new ProviderAdapterProcessError({
190 provider: "claudeCode",
191 sessionId,
192 detail: "Claude runtime stream failed.",
193 cause,
194 }),
195);
196```
197
198Portable fallback (already aligned with current server patterns):
199
200```ts
201const sdkMessageStream = Stream.async<ClaudeSdkMessage, ProviderAdapterProcessError>((emit) => {
202 let cancelled = false;
203 void (async () => {
204 try {
205 for await (const message of session.result) {
206 if (cancelled) break;
207 emit.single(message);
208 }
209 emit.end();
210 } catch (cause) {
211 emit.fail(
212 new ProviderAdapterProcessError({
213 provider: "claudeCode",
214 sessionId,
215 detail: "Claude runtime stream failed.",
216 cause,
217 }),
218 );
219 }
220 })();
221 return Effect.sync(() => {
222 cancelled = true;
223 });
224});
225```
226
227### 2.3 Canonical event mapping
228
229Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`.
230
231Initial mapping target:
232
2331. assistant text deltas -> `content.delta`
2342. final assistant text -> `item.completed` and/or `turn.completed`
2353. approval requests -> `request.opened`
2364. approval results -> `request.resolved`
2375. system lifecycle -> `session.*`, `thread.*`, `turn.*`
2386. errors -> `runtime.error`
2397. plan/proposed-plan content when derivable
240
241Implementation note:
242
2431. Keep raw Claude message on `raw` for debugging.
2442. Prefer canonical item/request kinds over provider-native enums.
2453. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently.
246
247### 2.4 Resume cursor strategy
248
249Define Claude-owned opaque resume state, e.g.:
250
251```ts
252interface ClaudeResumeCursor {
253 readonly version: 1;
254 readonly threadId?: string;
255 readonly sessionAt?: string;
256}
257```
258
259Rules:
260
2611. Serialize only adapter-owned state into `resumeCursor`.
2622. Parse/validate only inside Claude adapter.
2633. Store updated cursor when Claude runtime yields enough data to resume safely.
2644. Never overload orchestration thread id as Claude thread id.
265
266### 2.5 Interrupt and stop semantics
267
268Map orchestration stop/interrupt expectations onto SDK controls:
269
2701. `interruptTurn()` -> active query interrupt.
2712. `stopSession()` -> close session resources and prevent future sends.
2723. `rollbackThread()` -> see Phase 4.
273
274---
275
276## Phase 3: Provider service and composition
277
278### 3.1 Register Claude adapter
279
280Update provider registry layer to include Claude:
281
2821. add `claudeCode` -> `ClaudeAdapter`
2832. ensure `ProviderService.listProviderStatuses()` reports Claude availability
284
285### 3.2 Persist provider binding
286
287Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`.
288
289Required validation:
290
2911. Claude bindings survive restart.
2922. resume cursor remains opaque and round-trips untouched.
2933. stopAll + restart can recover Claude sessions when possible.
294
295### 3.3 Provider start routing
296
297Update `ProviderCommandReactor` / orchestration flow:
298
2991. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists.
3002. If a thread already has Claude session binding, reuse it.
3013. If provider switches between Codex and Claude, explicitly stop/rebind before next send.
302
303---
304
305## Phase 4: Checkpoint and revert strategy
306
307Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`.
308
309Pick one explicit strategy:
310
311### Option A: provider-native rewind
312
313If SDK/runtime supports safe rewind:
314
3151. implement in Claude adapter
3162. keep `CheckpointReactor` unchanged
317
318### Option B: session restart + state truncation shim
319
320If no native rewind exists:
321
3221. Claude adapter returns successful rollback by:
323 - stopping current Claude session
324 - clearing/rewriting stored Claude resume cursor to last safe resumable point
325 - forcing next turn to recreate session from persisted orchestration state
3262. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion.
327
328Whichever option is chosen:
329
3301. behavior must be deterministic
3312. checkpoint revert tests must pass under orchestration expectations
3323. user-visible activity log should explain failures clearly when provider rollback is impossible
333
334---
335
336## Phase 5: Web integration
337
338### 5.1 Provider picker and model picker
339
340Update web state/UI:
341
3421. allow choosing Claude as thread provider before first turn
3432. show Claude model list from provider-aware model helpers
3443. preserve existing Codex default behavior when provider omitted
345
346Likely touch points:
347
3481. `apps/web/src/store.ts`
3492. `apps/web/src/components/ChatView.tsx`
3503. `apps/web/src/types.ts`
3514. `packages/shared/src/model.ts`
352
353### 5.2 Settings for Claude executable/options
354
355Add app settings if needed for:
356
3571. Claude binary path
3582. default permission mode
3593. default max thinking tokens
360
361Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`.
362
363### 5.3 Session rendering
364
365No new WS channel should be needed. Claude should appear through existing:
366
3671. thread messages
3682. activities/worklog
3693. approvals
3704. session state
3715. checkpoints/diffs
372
373---
374
375## Phase 6: Testing strategy
376
377### 6.1 Contract tests
378
379Cover:
380
3811. provider-aware model schemas
3822. provider field on turn-start command
3833. provider-specific start options schema
384
385### 6.2 Adapter layer tests
386
387Add `ClaudeAdapter.test.ts` covering:
388
3891. session start
3902. event mapping
3913. approval bridge
3924. resume cursor parse/serialize
3935. interrupt behavior
3946. rollback behavior or explicit unsupported error path
395
396Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests.
397
398### 6.3 Provider service integration tests
399
400Extend provider integration coverage so Claude is exercised through `ProviderService`:
401
4021. start Claude session
4032. send turn
4043. receive canonical runtime events
4054. restart/recover using persisted binding
406
407### 6.4 Orchestration integration tests
408
409Add/extend integration tests around:
410
4111. first-turn provider selection
4122. Claude approval requests routed through orchestration
4133. Claude runtime ingestion -> messages/activities/session updates
4144. checkpoint revert behavior under Claude
4155. stopAll/restart recovery
416
417These should validate real orchestration flows, not just adapter behavior.
418
419---
420
421## Phase 7: Rollout order
422
423Recommended implementation order:
424
4251. contracts/provider-aware models
4262. provider field on turn-start
4273. Claude adapter skeleton + start/send/stream
4284. canonical event mapping
4295. provider registry/service wiring
4306. orchestration recovery + checkpoint strategy
4317. web provider/model picker
4328. full integration tests
433
434---
435
436## Non-goals
437
4381. Reintroducing provider-specific WS methods/channels.
4392. Storing provider-native thread ids as orchestration ids.
4403. Bypassing orchestration engine for Claude-specific UI flows.
4414. Encoding Claude resume semantics outside adapter-owned `resumeCursor`.
AGENTS.md+1-1
......@@ -7,7 +7,7 @@
77
88## Project Snapshot
99
10T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
10T3 Code is a minimal web GUI for using coding agents like Codex and Claude.
1111
1212This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
1313
README.md+1-1
......@@ -1,6 +1,6 @@
11# T3 Code
22
3T3 Code is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon.
3T3 Code is a minimal web GUI for coding agents (currently Codex and Claude, more coming soon).
44
55## How to use
66
apps/server/integration/OrchestrationEngineHarness.integration.ts+2-1
......@@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import {
88 ApprovalRequestId,
9 ProviderKind,
910 type OrchestrationEvent,
1011 type OrchestrationThread,
1112} from "@t3tools/contracts";
......@@ -205,7 +206,7 @@ export interface OrchestrationIntegrationHarness {
205206}
206207
207208interface MakeOrchestrationIntegrationHarnessOptions {
208 readonly provider?: "codex";
209 readonly provider?: ProviderKind;
209210 readonly realCodex?: boolean;
210211}
211212
apps/server/integration/TestProviderAdapter.integration.ts+6-5
......@@ -10,6 +10,7 @@ import {
1010 ProviderTurnStartResult,
1111 ThreadId,
1212 TurnId,
13 ProviderKind,
1314} from "@t3tools/contracts";
1415import { Effect, Queue, Stream } from "effect";
1516
......@@ -35,7 +36,7 @@ export interface TestTurnResponse {
3536export type FixtureProviderRuntimeEvent = {
3637 readonly type: string;
3738 readonly eventId: EventId;
38 readonly provider: "codex";
39 readonly provider: ProviderKind;
3940 readonly createdAt: string;
4041 readonly threadId: string;
4142 readonly turnId?: string | undefined;
......@@ -177,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti
177178
178179export interface TestProviderAdapterHarness {
179180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
180 readonly provider: "codex";
181 readonly provider: ProviderKind;
181182 readonly queueTurnResponse: (
182183 threadId: ThreadId,
183184 response: TestTurnResponse,
......@@ -197,7 +198,7 @@ export interface TestProviderAdapterHarness {
197198}
198199
199200interface MakeTestProviderAdapterHarnessOptions {
200 readonly provider?: "codex";
201 readonly provider?: ProviderKind;
201202}
202203
203204function nowIso(): string {
......@@ -205,7 +206,7 @@ function nowIso(): string {
205206}
206207
207208function sessionNotFound(
208 provider: "codex",
209 provider: ProviderKind,
209210 threadId: ThreadId,
210211): ProviderAdapterSessionNotFoundError {
211212 return new ProviderAdapterSessionNotFoundError({
......@@ -215,7 +216,7 @@ function sessionNotFound(
215216}
216217
217218function missingSessionEffect(
218 provider: "codex",
219 provider: ProviderKind,
219220 threadId: ThreadId,
220221): Effect.Effect<never, ProviderAdapterError> {
221222 return Effect.fail(sessionNotFound(provider, threadId));
apps/server/integration/orchestrationEngine.integration.test.ts+424-3
......@@ -5,9 +5,11 @@ import {
55 ApprovalRequestId,
66 CommandId,
77 DEFAULT_PROVIDER_INTERACTION_MODE,
8 DEFAULT_MODEL_BY_PROVIDER,
89 EventId,
910 MessageId,
1011 ProjectId,
12 ProviderKind,
1113 ThreadId,
1214} from "@t3tools/contracts";
1315import { assert, it } from "@effect/vitest";
......@@ -36,7 +38,7 @@ const PROJECT_ID = asProjectId("project-1");
3638const THREAD_ID = ThreadId.makeUnsafe("thread-1");
3739const FIXTURE_TURN_ID = "fixture-turn";
3840const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
39type IntegrationProvider = "codex";
41type IntegrationProvider = ProviderKind;
4042
4143function nowIso() {
4244 return new Date().toISOString();
......@@ -105,6 +107,8 @@ function withRealCodexHarness<A, E>(
105107const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
106108 Effect.gen(function* () {
107109 const createdAt = nowIso();
110 const provider = harness.adapterHarness?.provider ?? "codex";
111 const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider];
108112
109113 yield* harness.engine.dispatch({
110114 type: "project.create",
......@@ -112,7 +116,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
112116 projectId: PROJECT_ID,
113117 title: "Integration Project",
114118 workspaceRoot: harness.workspaceDir,
115 defaultModel: "gpt-5-codex",
119 defaultModel,
116120 createdAt,
117121 });
118122
......@@ -122,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
122126 threadId: THREAD_ID,
123127 projectId: PROJECT_ID,
124128 title: "Integration Thread",
125 model: "gpt-5-codex",
129 model: defaultModel,
126130 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
127131 runtimeMode: "approval-required",
128132 branch: null,
......@@ -882,3 +886,420 @@ it.live(
882886 }),
883887 ),
884888);
889
890it.live("starts a claudeAgent session on first turn when provider is requested", () =>
891 withHarness(
892 (harness) =>
893 Effect.gen(function* () {
894 yield* seedProjectAndThread(harness);
895
896 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
897 events: [
898 {
899 type: "turn.started",
900 ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeAgent"),
901 threadId: THREAD_ID,
902 turnId: FIXTURE_TURN_ID,
903 },
904 {
905 type: "message.delta",
906 ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeAgent"),
907 threadId: THREAD_ID,
908 turnId: FIXTURE_TURN_ID,
909 delta: "Claude first turn.\n",
910 },
911 {
912 type: "turn.completed",
913 ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeAgent"),
914 threadId: THREAD_ID,
915 turnId: FIXTURE_TURN_ID,
916 status: "completed",
917 },
918 ],
919 });
920
921 yield* startTurn({
922 harness,
923 commandId: "cmd-turn-start-claude-initial",
924 messageId: "msg-user-claude-initial",
925 text: "Use Claude",
926 provider: "claudeAgent",
927 });
928
929 const thread = yield* harness.waitForThread(
930 THREAD_ID,
931 (entry) =>
932 entry.session?.providerName === "claudeAgent" &&
933 entry.session.status === "ready" &&
934 entry.messages.some(
935 (message) => message.role === "assistant" && message.text === "Claude first turn.\n",
936 ),
937 );
938 assert.equal(thread.session?.providerName, "claudeAgent");
939 }),
940 "claudeAgent",
941 ),
942);
943
944it.live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () =>
945 withHarness(
946 (harness) =>
947 Effect.gen(function* () {
948 yield* seedProjectAndThread(harness);
949
950 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
951 events: [
952 {
953 type: "turn.started",
954 ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeAgent"),
955 threadId: THREAD_ID,
956 turnId: FIXTURE_TURN_ID,
957 },
958 {
959 type: "message.delta",
960 ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeAgent"),
961 threadId: THREAD_ID,
962 turnId: FIXTURE_TURN_ID,
963 delta: "Turn before restart.\n",
964 },
965 {
966 type: "turn.completed",
967 ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeAgent"),
968 threadId: THREAD_ID,
969 turnId: FIXTURE_TURN_ID,
970 status: "completed",
971 },
972 ],
973 });
974
975 yield* startTurn({
976 harness,
977 commandId: "cmd-turn-start-claude-recover-1",
978 messageId: "msg-user-claude-recover-1",
979 text: "Before restart",
980 provider: "claudeAgent",
981 });
982
983 yield* harness.waitForThread(
984 THREAD_ID,
985 (entry) =>
986 entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1",
987 );
988
989 yield* harness.adapterHarness!.adapter.stopAll();
990 yield* waitForSync(
991 () => harness.adapterHarness!.listActiveSessionIds(),
992 (sessionIds) => sessionIds.length === 0,
993 "provider stopAll",
994 );
995
996 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
997 events: [
998 {
999 type: "turn.started",
1000 ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeAgent"),
1001 threadId: THREAD_ID,
1002 turnId: FIXTURE_TURN_ID,
1003 },
1004 {
1005 type: "message.delta",
1006 ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeAgent"),
1007 threadId: THREAD_ID,
1008 turnId: FIXTURE_TURN_ID,
1009 delta: "Turn after restart.\n",
1010 },
1011 {
1012 type: "turn.completed",
1013 ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeAgent"),
1014 threadId: THREAD_ID,
1015 turnId: FIXTURE_TURN_ID,
1016 status: "completed",
1017 },
1018 ],
1019 });
1020
1021 yield* startTurn({
1022 harness,
1023 commandId: "cmd-turn-start-claude-recover-2",
1024 messageId: "msg-user-claude-recover-2",
1025 text: "After restart",
1026 });
1027 yield* waitForSync(
1028 () => harness.adapterHarness!.getStartCount(),
1029 (count) => count === 2,
1030 "claude provider recovery start",
1031 );
1032
1033 const recoveredThread = yield* harness.waitForThread(
1034 THREAD_ID,
1035 (entry) =>
1036 entry.session?.providerName === "claudeAgent" &&
1037 entry.messages.some(
1038 (message) => message.role === "user" && message.text === "After restart",
1039 ) &&
1040 !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
1041 );
1042 assert.equal(recoveredThread.session?.providerName, "claudeAgent");
1043 assert.equal(recoveredThread.session?.threadId, "thread-1");
1044 }),
1045 "claudeAgent",
1046 ),
1047);
1048
1049it.live("forwards claudeAgent approval responses to the provider session", () =>
1050 withHarness(
1051 (harness) =>
1052 Effect.gen(function* () {
1053 yield* seedProjectAndThread(harness);
1054
1055 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
1056 events: [
1057 {
1058 type: "turn.started",
1059 ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeAgent"),
1060 threadId: THREAD_ID,
1061 turnId: FIXTURE_TURN_ID,
1062 },
1063 {
1064 type: "approval.requested",
1065 ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeAgent"),
1066 threadId: THREAD_ID,
1067 turnId: FIXTURE_TURN_ID,
1068 requestId: APPROVAL_REQUEST_ID,
1069 requestKind: "command",
1070 detail: "Approve Claude tool call",
1071 },
1072 {
1073 type: "turn.completed",
1074 ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeAgent"),
1075 threadId: THREAD_ID,
1076 turnId: FIXTURE_TURN_ID,
1077 status: "completed",
1078 },
1079 ],
1080 });
1081
1082 yield* startTurn({
1083 harness,
1084 commandId: "cmd-turn-start-claude-approval",
1085 messageId: "msg-user-claude-approval",
1086 text: "Need approval",
1087 provider: "claudeAgent",
1088 });
1089
1090 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
1091 entry.activities.some((activity) => activity.kind === "approval.requested"),
1092 );
1093 assert.equal(thread.session?.threadId, "thread-1");
1094
1095 yield* harness.engine.dispatch({
1096 type: "thread.approval.respond",
1097 commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"),
1098 threadId: THREAD_ID,
1099 requestId: APPROVAL_REQUEST_ID,
1100 decision: "accept",
1101 createdAt: nowIso(),
1102 });
1103
1104 yield* harness.waitForPendingApproval(
1105 "req-approval-1",
1106 (row) => row.status === "resolved" && row.decision === "accept",
1107 );
1108
1109 const approvalResponses = yield* waitForSync(
1110 () => harness.adapterHarness!.getApprovalResponses(THREAD_ID),
1111 (responses) => responses.length === 1,
1112 "claude provider approval response",
1113 );
1114 assert.equal(approvalResponses[0]?.decision, "accept");
1115 }),
1116 "claudeAgent",
1117 ),
1118);
1119
1120it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
1121 withHarness(
1122 (harness) =>
1123 Effect.gen(function* () {
1124 yield* seedProjectAndThread(harness);
1125
1126 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
1127 events: [
1128 {
1129 type: "turn.started",
1130 ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeAgent"),
1131 threadId: THREAD_ID,
1132 turnId: FIXTURE_TURN_ID,
1133 },
1134 {
1135 type: "message.delta",
1136 ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeAgent"),
1137 threadId: THREAD_ID,
1138 turnId: FIXTURE_TURN_ID,
1139 delta: "Long running output.\n",
1140 },
1141 {
1142 type: "turn.completed",
1143 ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeAgent"),
1144 threadId: THREAD_ID,
1145 turnId: FIXTURE_TURN_ID,
1146 status: "completed",
1147 },
1148 ],
1149 });
1150
1151 yield* startTurn({
1152 harness,
1153 commandId: "cmd-turn-start-claude-interrupt",
1154 messageId: "msg-user-claude-interrupt",
1155 text: "Start long turn",
1156 provider: "claudeAgent",
1157 });
1158
1159 const thread = yield* harness.waitForThread(
1160 THREAD_ID,
1161 (entry) => entry.session?.threadId === "thread-1",
1162 );
1163 assert.equal(thread.session?.threadId, "thread-1");
1164
1165 yield* harness.engine.dispatch({
1166 type: "thread.turn.interrupt",
1167 commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"),
1168 threadId: THREAD_ID,
1169 createdAt: nowIso(),
1170 });
1171 yield* harness.waitForDomainEvent(
1172 (event) => event.type === "thread.turn-interrupt-requested",
1173 );
1174
1175 const interruptCalls = yield* waitForSync(
1176 () => harness.adapterHarness!.getInterruptCalls(THREAD_ID),
1177 (calls) => calls.length === 1,
1178 "claude provider interrupt call",
1179 );
1180 assert.equal(interruptCalls.length, 1);
1181 }),
1182 "claudeAgent",
1183 ),
1184);
1185
1186it.live("reverts claudeAgent turns and rolls back provider conversation state", () =>
1187 withHarness(
1188 (harness) =>
1189 Effect.gen(function* () {
1190 yield* seedProjectAndThread(harness);
1191
1192 yield* harness.adapterHarness!.queueTurnResponseForNextSession({
1193 events: [
1194 {
1195 type: "turn.started",
1196 ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"),
1197 threadId: THREAD_ID,
1198 turnId: FIXTURE_TURN_ID,
1199 },
1200 {
1201 type: "message.delta",
1202 ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"),
1203 threadId: THREAD_ID,
1204 turnId: FIXTURE_TURN_ID,
1205 delta: "README -> v2\n",
1206 },
1207 {
1208 type: "turn.completed",
1209 ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"),
1210 threadId: THREAD_ID,
1211 turnId: FIXTURE_TURN_ID,
1212 status: "completed",
1213 },
1214 ],
1215 mutateWorkspace: ({ cwd }) =>
1216 Effect.sync(() => {
1217 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
1218 }),
1219 });
1220
1221 yield* startTurn({
1222 harness,
1223 commandId: "cmd-turn-start-claude-revert-1",
1224 messageId: "msg-user-claude-revert-1",
1225 text: "First Claude edit",
1226 provider: "claudeAgent",
1227 });
1228
1229 yield* harness.waitForThread(
1230 THREAD_ID,
1231 (entry) =>
1232 entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1",
1233 );
1234
1235 yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, {
1236 events: [
1237 {
1238 type: "turn.started",
1239 ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"),
1240 threadId: THREAD_ID,
1241 turnId: FIXTURE_TURN_ID,
1242 },
1243 {
1244 type: "message.delta",
1245 ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"),
1246 threadId: THREAD_ID,
1247 turnId: FIXTURE_TURN_ID,
1248 delta: "README -> v3\n",
1249 },
1250 {
1251 type: "turn.completed",
1252 ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"),
1253 threadId: THREAD_ID,
1254 turnId: FIXTURE_TURN_ID,
1255 status: "completed",
1256 },
1257 ],
1258 mutateWorkspace: ({ cwd }) =>
1259 Effect.sync(() => {
1260 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
1261 }),
1262 });
1263
1264 yield* startTurn({
1265 harness,
1266 commandId: "cmd-turn-start-claude-revert-2",
1267 messageId: "msg-user-claude-revert-2",
1268 text: "Second Claude edit",
1269 });
1270
1271 yield* harness.waitForThread(
1272 THREAD_ID,
1273 (entry) =>
1274 entry.latestTurn?.turnId === "turn-2" &&
1275 entry.checkpoints.length === 2 &&
1276 entry.session?.providerName === "claudeAgent",
1277 );
1278
1279 yield* harness.engine.dispatch({
1280 type: "thread.checkpoint.revert",
1281 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"),
1282 threadId: THREAD_ID,
1283 turnCount: 1,
1284 createdAt: nowIso(),
1285 });
1286
1287 const revertedThread = yield* harness.waitForThread(
1288 THREAD_ID,
1289 (entry) =>
1290 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,
1291 );
1292 assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1);
1293 assert.equal(
1294 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),
1295 true,
1296 );
1297 assert.equal(
1298 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
1299 false,
1300 );
1301 assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]);
1302 }),
1303 "claudeAgent",
1304 ),
1305);
apps/server/package.json+1
......@@ -22,6 +22,7 @@
2222 "test": "vitest run"
2323 },
2424 "dependencies": {
25 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
2526 "@effect/platform-node": "catalog:",
2627 "@effect/sql-sqlite-bun": "catalog:",
2728 "@pierre/diffs": "^1.1.0-beta.16",
apps/server/src/codexAppServerManager.test.ts+187
......@@ -37,6 +37,7 @@ function createSendTurnHarness() {
3737 planType: null,
3838 sparkEnabled: true,
3939 },
40 collabReceiverTurns: new Map(),
4041 };
4142
4243 const requireSession = vi
......@@ -75,6 +76,7 @@ function createThreadControlHarness() {
7576 createdAt: "2026-02-10T00:00:00.000Z",
7677 updatedAt: "2026-02-10T00:00:00.000Z",
7778 },
79 collabReceiverTurns: new Map(),
7880 };
7981
8082 const requireSession = vi
......@@ -117,6 +119,7 @@ function createPendingUserInputHarness() {
117119 },
118120 ],
119121 ]),
122 collabReceiverTurns: new Map(),
120123 };
121124
122125 const requireSession = vi
......@@ -135,6 +138,43 @@ function createPendingUserInputHarness() {
135138 return { manager, context, requireSession, writeMessage, emitEvent };
136139}
137140
141function createCollabNotificationHarness() {
142 const manager = new CodexAppServerManager();
143 const context = {
144 session: {
145 provider: "codex",
146 status: "running",
147 threadId: asThreadId("thread_1"),
148 runtimeMode: "full-access",
149 model: "gpt-5.3-codex",
150 activeTurnId: "turn_parent",
151 resumeCursor: { threadId: "provider_parent" },
152 createdAt: "2026-02-10T00:00:00.000Z",
153 updatedAt: "2026-02-10T00:00:00.000Z",
154 },
155 account: {
156 type: "unknown",
157 planType: null,
158 sparkEnabled: true,
159 },
160 pending: new Map(),
161 pendingApprovals: new Map(),
162 pendingUserInputs: new Map(),
163 collabReceiverTurns: new Map<string, string>(),
164 nextRequestId: 1,
165 stopping: false,
166 };
167
168 const emitEvent = vi
169 .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent")
170 .mockImplementation(() => {});
171 const updateSession = vi
172 .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
173 .mockImplementation(() => {});
174
175 return { manager, context, emitEvent, updateSession };
176}
177
138178describe("classifyCodexStderrLine", () => {
139179 it("ignores empty lines", () => {
140180 expect(classifyCodexStderrLine(" ")).toBeNull();
......@@ -721,6 +761,7 @@ describe("respondToUserInput", () => {
721761 },
722762 pendingApprovals: new Map(),
723763 pendingUserInputs: new Map(),
764 collabReceiverTurns: new Map(),
724765 };
725766 type ApprovalRequestContext = {
726767 session: typeof context.session;
......@@ -748,6 +789,152 @@ describe("respondToUserInput", () => {
748789 });
749790});
750791
792describe("collab child conversation routing", () => {
793 it("rewrites child notification turn ids onto the parent turn", () => {
794 const { manager, context, emitEvent } = createCollabNotificationHarness();
795
796 (
797 manager as unknown as {
798 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
799 }
800 ).handleServerNotification(context, {
801 method: "item/completed",
802 params: {
803 item: {
804 type: "collabAgentToolCall",
805 id: "call_collab_1",
806 receiverThreadIds: ["child_provider_1"],
807 },
808 threadId: "provider_parent",
809 turnId: "turn_parent",
810 },
811 });
812
813 (
814 manager as unknown as {
815 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
816 }
817 ).handleServerNotification(context, {
818 method: "item/agentMessage/delta",
819 params: {
820 threadId: "child_provider_1",
821 turnId: "turn_child_1",
822 itemId: "msg_child_1",
823 delta: "working",
824 },
825 });
826
827 expect(emitEvent).toHaveBeenLastCalledWith(
828 expect.objectContaining({
829 method: "item/agentMessage/delta",
830 turnId: "turn_parent",
831 itemId: "msg_child_1",
832 }),
833 );
834 });
835
836 it("suppresses child lifecycle notifications so they cannot replace the parent turn", () => {
837 const { manager, context, emitEvent, updateSession } = createCollabNotificationHarness();
838
839 (
840 manager as unknown as {
841 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
842 }
843 ).handleServerNotification(context, {
844 method: "item/completed",
845 params: {
846 item: {
847 type: "collabAgentToolCall",
848 id: "call_collab_1",
849 receiverThreadIds: ["child_provider_1"],
850 },
851 threadId: "provider_parent",
852 turnId: "turn_parent",
853 },
854 });
855 emitEvent.mockClear();
856 updateSession.mockClear();
857
858 (
859 manager as unknown as {
860 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
861 }
862 ).handleServerNotification(context, {
863 method: "turn/started",
864 params: {
865 threadId: "child_provider_1",
866 turn: { id: "turn_child_1" },
867 },
868 });
869
870 (
871 manager as unknown as {
872 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
873 }
874 ).handleServerNotification(context, {
875 method: "turn/completed",
876 params: {
877 threadId: "child_provider_1",
878 turn: { id: "turn_child_1", status: "completed" },
879 },
880 });
881
882 expect(emitEvent).not.toHaveBeenCalled();
883 expect(updateSession).not.toHaveBeenCalled();
884 });
885
886 it("rewrites child approval requests onto the parent turn", () => {
887 const { manager, context, emitEvent } = createCollabNotificationHarness();
888
889 (
890 manager as unknown as {
891 handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
892 }
893 ).handleServerNotification(context, {
894 method: "item/completed",
895 params: {
896 item: {
897 type: "collabAgentToolCall",
898 id: "call_collab_1",
899 receiverThreadIds: ["child_provider_1"],
900 },
901 threadId: "provider_parent",
902 turnId: "turn_parent",
903 },
904 });
905 emitEvent.mockClear();
906
907 (
908 manager as unknown as {
909 handleServerRequest: (context: unknown, request: Record<string, unknown>) => void;
910 }
911 ).handleServerRequest(context, {
912 id: 42,
913 method: "item/commandExecution/requestApproval",
914 params: {
915 threadId: "child_provider_1",
916 turnId: "turn_child_1",
917 itemId: "call_child_1",
918 command: "bun install",
919 },
920 });
921
922 expect(Array.from(context.pendingApprovals.values())[0]).toEqual(
923 expect.objectContaining({
924 turnId: "turn_parent",
925 itemId: "call_child_1",
926 }),
927 );
928 expect(emitEvent).toHaveBeenCalledWith(
929 expect.objectContaining({
930 method: "item/commandExecution/requestApproval",
931 turnId: "turn_parent",
932 itemId: "call_child_1",
933 }),
934 );
935 });
936});
937
751938describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
752939 it("keeps prior thread history when resuming with a changed runtime mode", async () => {
753940 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
apps/server/src/codexAppServerManager.ts+94-10
......@@ -70,6 +70,7 @@ interface CodexSessionContext {
7070 pending: Map<PendingRequestKey, PendingRequest>;
7171 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
7272 pendingUserInputs: Map<ApprovalRequestId, PendingUserInputRequest>;
73 collabReceiverTurns: Map<string, TurnId>;
7374 nextRequestId: number;
7475 stopping: boolean;
7576}
......@@ -571,6 +572,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
571572 pending: new Map(),
572573 pendingApprovals: new Map(),
573574 pendingUserInputs: new Map(),
575 collabReceiverTurns: new Map(),
574576 nextRequestId: 1,
575577 stopping: false,
576578 };
......@@ -732,6 +734,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
732734
733735 async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
734736 const context = this.requireSession(input.threadId);
737 context.collabReceiverTurns.clear();
735738
736739 const turnInput: Array<
737740 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
......@@ -1121,7 +1124,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11211124 context: CodexSessionContext,
11221125 notification: JsonRpcNotification,
11231126 ): void {
1124 const route = this.readRouteFields(notification.params);
1127 const rawRoute = this.readRouteFields(notification.params);
1128 this.rememberCollabReceiverTurns(context, notification.params, rawRoute.turnId);
1129 const childParentTurnId = this.readChildParentTurnId(context, notification.params);
1130 const isChildConversation = childParentTurnId !== undefined;
1131 if (
1132 isChildConversation &&
1133 this.shouldSuppressChildConversationNotification(notification.method)
1134 ) {
1135 return;
1136 }
11251137 const textDelta =
11261138 notification.method === "item/agentMessage/delta"
11271139 ? this.readString(notification.params, "delta")
......@@ -1134,8 +1146,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11341146 threadId: context.session.threadId,
11351147 createdAt: new Date().toISOString(),
11361148 method: notification.method,
1137 turnId: route.turnId,
1138 itemId: route.itemId,
1149 ...((childParentTurnId ?? rawRoute.turnId)
1150 ? { turnId: childParentTurnId ?? rawRoute.turnId }
1151 : {}),
1152 ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}),
11391153 textDelta,
11401154 payload: notification.params,
11411155 });
......@@ -1151,6 +1165,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11511165 }
11521166
11531167 if (notification.method === "turn/started") {
1168 if (isChildConversation) {
1169 return;
1170 }
11541171 const turnId = toTurnId(this.readString(this.readObject(notification.params)?.turn, "id"));
11551172 this.updateSession(context, {
11561173 status: "running",
......@@ -1160,6 +1177,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11601177 }
11611178
11621179 if (notification.method === "turn/completed") {
1180 if (isChildConversation) {
1181 return;
1182 }
1183 context.collabReceiverTurns.clear();
11631184 const turn = this.readObject(notification.params, "turn");
11641185 const status = this.readString(turn, "status");
11651186 const errorMessage = this.readString(this.readObject(turn, "error"), "message");
......@@ -1172,6 +1193,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11721193 }
11731194
11741195 if (notification.method === "error") {
1196 if (isChildConversation) {
1197 return;
1198 }
11751199 const message = this.readString(this.readObject(notification.params)?.error, "message");
11761200 const willRetry = this.readBoolean(notification.params, "willRetry");
11771201
......@@ -1183,7 +1207,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11831207 }
11841208
11851209 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {
1186 const route = this.readRouteFields(request.params);
1210 const rawRoute = this.readRouteFields(request.params);
1211 const childParentTurnId = this.readChildParentTurnId(context, request.params);
1212 const effectiveTurnId = childParentTurnId ?? rawRoute.turnId;
11871213 const requestKind = this.requestKindForMethod(request.method);
11881214 let requestId: ApprovalRequestId | undefined;
11891215 if (requestKind) {
......@@ -1199,8 +1225,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
11991225 : "item/fileChange/requestApproval",
12001226 requestKind,
12011227 threadId: context.session.threadId,
1202 ...(route.turnId ? { turnId: route.turnId } : {}),
1203 ...(route.itemId ? { itemId: route.itemId } : {}),
1228 ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
1229 ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}),
12041230 };
12051231 context.pendingApprovals.set(requestId, pendingRequest);
12061232 }
......@@ -1211,8 +1237,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
12111237 requestId,
12121238 jsonRpcId: request.id,
12131239 threadId: context.session.threadId,
1214 ...(route.turnId ? { turnId: route.turnId } : {}),
1215 ...(route.itemId ? { itemId: route.itemId } : {}),
1240 ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
1241 ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}),
12161242 });
12171243 }
12181244
......@@ -1223,8 +1249,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
12231249 threadId: context.session.threadId,
12241250 createdAt: new Date().toISOString(),
12251251 method: request.method,
1226 turnId: route.turnId,
1227 itemId: route.itemId,
1252 ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}),
1253 ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}),
12281254 requestId,
12291255 requestKind,
12301256 payload: request.params,
......@@ -1451,6 +1477,64 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
14511477 return route;
14521478 }
14531479
1480 private readProviderConversationId(params: unknown): string | undefined {
1481 return (
1482 this.readString(params, "threadId") ??
1483 this.readString(this.readObject(params, "thread"), "id") ??
1484 this.readString(params, "conversationId")
1485 );
1486 }
1487
1488 private readChildParentTurnId(context: CodexSessionContext, params: unknown): TurnId | undefined {
1489 const providerConversationId = this.readProviderConversationId(params);
1490 if (!providerConversationId) {
1491 return undefined;
1492 }
1493 return context.collabReceiverTurns.get(providerConversationId);
1494 }
1495
1496 private rememberCollabReceiverTurns(
1497 context: CodexSessionContext,
1498 params: unknown,
1499 parentTurnId: TurnId | undefined,
1500 ): void {
1501 if (!parentTurnId) {
1502 return;
1503 }
1504 const payload = this.readObject(params);
1505 const item = this.readObject(payload, "item") ?? payload;
1506 const itemType = this.readString(item, "type") ?? this.readString(item, "kind");
1507 if (itemType !== "collabAgentToolCall") {
1508 return;
1509 }
1510
1511 const receiverThreadIds =
1512 this.readArray(item, "receiverThreadIds")
1513 ?.map((value) => (typeof value === "string" ? value : null))
1514 .filter((value): value is string => value !== null) ?? [];
1515 for (const receiverThreadId of receiverThreadIds) {
1516 context.collabReceiverTurns.set(receiverThreadId, parentTurnId);
1517 }
1518 }
1519
1520 private shouldSuppressChildConversationNotification(method: string): boolean {
1521 return (
1522 method === "thread/started" ||
1523 method === "thread/status/changed" ||
1524 method === "thread/archived" ||
1525 method === "thread/unarchived" ||
1526 method === "thread/closed" ||
1527 method === "thread/compacted" ||
1528 method === "thread/name/updated" ||
1529 method === "thread/tokenUsage/updated" ||
1530 method === "turn/started" ||
1531 method === "turn/completed" ||
1532 method === "turn/aborted" ||
1533 method === "turn/plan/updated" ||
1534 method === "item/plan/delta"
1535 );
1536 }
1537
14541538 private readObject(value: unknown, key?: string): Record<string, unknown> | undefined {
14551539 const target =
14561540 key === undefined
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+134-3
......@@ -3,7 +3,7 @@ import os from "node:os";
33import path from "node:path";
44import { execFileSync } from "node:child_process";
55
6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
6import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
77import {
88 CommandId,
99 DEFAULT_PROVIDER_INTERACTION_MODE,
......@@ -44,7 +44,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4444type LegacyProviderRuntimeEvent = {
4545 readonly type: string;
4646 readonly eventId: EventId;
47 readonly provider: "codex";
47 readonly provider: ProviderKind;
4848 readonly createdAt: string;
4949 readonly threadId: ThreadId;
5050 readonly turnId?: string | undefined;
......@@ -235,6 +235,7 @@ describe("CheckpointReactor", () => {
235235 readonly projectWorkspaceRoot?: string;
236236 readonly threadWorktreePath?: string | null;
237237 readonly providerSessionCwd?: string;
238 readonly providerName?: ProviderKind;
238239 }) {
239240 const cwd = createGitRepository();
240241 tempDirs.push(cwd);
......@@ -242,7 +243,7 @@ describe("CheckpointReactor", () => {
242243 cwd,
243244 options?.hasSession ?? true,
244245 options?.providerSessionCwd ?? cwd,
245 "codex",
246 options?.providerName ?? "codex",
246247 );
247248 const orchestrationLayer = OrchestrationEngineLive.pipe(
248249 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -477,6 +478,67 @@ describe("CheckpointReactor", () => {
477478 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
478479 });
479480
481 it("captures pre-turn and completion checkpoints for claude runtime events", async () => {
482 const harness = await createHarness({
483 seedFilesystemCheckpoints: false,
484 providerName: "claudeAgent",
485 });
486 const createdAt = new Date().toISOString();
487
488 await Effect.runPromise(
489 harness.engine.dispatch({
490 type: "thread.session.set",
491 commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"),
492 threadId: ThreadId.makeUnsafe("thread-1"),
493 session: {
494 threadId: ThreadId.makeUnsafe("thread-1"),
495 status: "ready",
496 providerName: "claudeAgent",
497 runtimeMode: "approval-required",
498 activeTurnId: null,
499 lastError: null,
500 updatedAt: createdAt,
501 },
502 createdAt,
503 }),
504 );
505
506 harness.provider.emit({
507 type: "turn.started",
508 eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),
509 provider: "claudeAgent",
510 createdAt: new Date().toISOString(),
511 threadId: ThreadId.makeUnsafe("thread-1"),
512 turnId: asTurnId("turn-claude-1"),
513 });
514 await waitForGitRefExists(
515 harness.cwd,
516 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
517 );
518
519 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
520 harness.provider.emit({
521 type: "turn.completed",
522 eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),
523 provider: "claudeAgent",
524 createdAt: new Date().toISOString(),
525 threadId: ThreadId.makeUnsafe("thread-1"),
526 turnId: asTurnId("turn-claude-1"),
527 payload: { state: "completed" },
528 });
529
530 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
531 const thread = await waitForThread(
532 harness.engine,
533 (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1,
534 );
535
536 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
537 expect(
538 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),
539 ).toBe(true);
540 });
541
480542 it("appends capture failure activity when turn diff summary cannot be derived", async () => {
481543 const harness = await createHarness({ seedFilesystemCheckpoints: false });
482544 const createdAt = new Date().toISOString();
......@@ -792,6 +854,75 @@ describe("CheckpointReactor", () => {
792854 ).toBe(false);
793855 });
794856
857 it("executes provider revert and emits thread.reverted for claude sessions", async () => {
858 const harness = await createHarness({ providerName: "claudeAgent" });
859 const createdAt = new Date().toISOString();
860
861 await Effect.runPromise(
862 harness.engine.dispatch({
863 type: "thread.session.set",
864 commandId: CommandId.makeUnsafe("cmd-session-set-claude"),
865 threadId: ThreadId.makeUnsafe("thread-1"),
866 session: {
867 threadId: ThreadId.makeUnsafe("thread-1"),
868 status: "ready",
869 providerName: "claudeAgent",
870 runtimeMode: "approval-required",
871 activeTurnId: null,
872 lastError: null,
873 updatedAt: createdAt,
874 },
875 createdAt,
876 }),
877 );
878
879 await Effect.runPromise(
880 harness.engine.dispatch({
881 type: "thread.turn.diff.complete",
882 commandId: CommandId.makeUnsafe("cmd-diff-claude-1"),
883 threadId: ThreadId.makeUnsafe("thread-1"),
884 turnId: asTurnId("turn-claude-1"),
885 completedAt: createdAt,
886 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
887 status: "ready",
888 files: [],
889 checkpointTurnCount: 1,
890 createdAt,
891 }),
892 );
893 await Effect.runPromise(
894 harness.engine.dispatch({
895 type: "thread.turn.diff.complete",
896 commandId: CommandId.makeUnsafe("cmd-diff-claude-2"),
897 threadId: ThreadId.makeUnsafe("thread-1"),
898 turnId: asTurnId("turn-claude-2"),
899 completedAt: createdAt,
900 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
901 status: "ready",
902 files: [],
903 checkpointTurnCount: 2,
904 createdAt,
905 }),
906 );
907
908 await Effect.runPromise(
909 harness.engine.dispatch({
910 type: "thread.checkpoint.revert",
911 commandId: CommandId.makeUnsafe("cmd-revert-request-claude"),
912 threadId: ThreadId.makeUnsafe("thread-1"),
913 turnCount: 1,
914 createdAt,
915 }),
916 );
917
918 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
919 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
920 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
921 threadId: ThreadId.makeUnsafe("thread-1"),
922 numTurns: 1,
923 });
924 });
925
795926 it("processes consecutive revert requests with deterministic rollback sequencing", async () => {
796927 const harness = await createHarness();
797928 const createdAt = new Date().toISOString();
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+12
......@@ -1729,6 +1729,8 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
17291729 const threadId = ThreadId.makeUnsafe("thread-restart");
17301730 const turnId = TurnId.makeUnsafe("turn-restart");
17311731 const messageId = MessageId.makeUnsafe("message-restart");
1732 const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source");
1733 const sourcePlanId = "plan-source";
17321734 const turnStartedAt = "2026-02-26T14:00:00.000Z";
17331735 const sessionSetAt = "2026-02-26T14:00:05.000Z";
17341736
......@@ -1749,6 +1751,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
17491751 payload: {
17501752 threadId,
17511753 messageId,
1754 sourceProposedPlan: {
1755 threadId: sourcePlanThreadId,
1756 planId: sourcePlanId,
1757 },
17521758 runtimeMode: "approval-required",
17531759 createdAt: turnStartedAt,
17541760 },
......@@ -1800,11 +1806,15 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
18001806 return yield* sql<{
18011807 readonly turnId: string;
18021808 readonly userMessageId: string | null;
1809 readonly sourceProposedPlanThreadId: string | null;
1810 readonly sourceProposedPlanId: string | null;
18031811 readonly startedAt: string;
18041812 }>`
18051813 SELECT
18061814 turn_id AS "turnId",
18071815 pending_message_id AS "userMessageId",
1816 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
1817 source_proposed_plan_id AS "sourceProposedPlanId",
18081818 started_at AS "startedAt"
18091819 FROM projection_turns
18101820 WHERE turn_id = ${turnId}
......@@ -1815,6 +1825,8 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
18151825 {
18161826 turnId: "turn-restart",
18171827 userMessageId: "message-restart",
1828 sourceProposedPlanThreadId: "thread-plan-source",
1829 sourceProposedPlanId: "plan-source",
18181830 startedAt: turnStartedAt,
18191831 },
18201832 ]);
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+22
......@@ -808,6 +808,16 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
808808 pendingMessageId:
809809 existingTurn.value.pendingMessageId ??
810810 (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null),
811 sourceProposedPlanThreadId:
812 existingTurn.value.sourceProposedPlanThreadId ??
813 (Option.isSome(pendingTurnStart)
814 ? pendingTurnStart.value.sourceProposedPlanThreadId
815 : null),
816 sourceProposedPlanId:
817 existingTurn.value.sourceProposedPlanId ??
818 (Option.isSome(pendingTurnStart)
819 ? pendingTurnStart.value.sourceProposedPlanId
820 : null),
811821 startedAt:
812822 existingTurn.value.startedAt ??
813823 (Option.isSome(pendingTurnStart)
......@@ -826,6 +836,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
826836 pendingMessageId: Option.isSome(pendingTurnStart)
827837 ? pendingTurnStart.value.messageId
828838 : null,
839 sourceProposedPlanThreadId: Option.isSome(pendingTurnStart)
840 ? pendingTurnStart.value.sourceProposedPlanThreadId
841 : null,
842 sourceProposedPlanId: Option.isSome(pendingTurnStart)
843 ? pendingTurnStart.value.sourceProposedPlanId
844 : null,
829845 assistantMessageId: null,
830846 state: "running",
831847 requestedAt: Option.isSome(pendingTurnStart)
......@@ -879,6 +895,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
879895 turnId: event.payload.turnId,
880896 threadId: event.payload.threadId,
881897 pendingMessageId: null,
898 sourceProposedPlanThreadId: null,
899 sourceProposedPlanId: null,
882900 assistantMessageId: event.payload.messageId,
883901 state: event.payload.streaming ? "running" : "completed",
884902 requestedAt: event.payload.createdAt,
......@@ -914,6 +932,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
914932 turnId: event.payload.turnId,
915933 threadId: event.payload.threadId,
916934 pendingMessageId: null,
935 sourceProposedPlanThreadId: null,
936 sourceProposedPlanId: null,
917937 assistantMessageId: null,
918938 state: "interrupted",
919939 requestedAt: event.payload.createdAt,
......@@ -958,6 +978,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
958978 turnId: event.payload.turnId,
959979 threadId: event.payload.threadId,
960980 pendingMessageId: null,
981 sourceProposedPlanThreadId: null,
982 sourceProposedPlanId: null,
961983 assistantMessageId: event.payload.assistantMessageId,
962984 state: nextState,
963985 requestedAt: event.payload.completedAt,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8
......@@ -178,6 +178,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
178178 thread_id,
179179 turn_id,
180180 pending_message_id,
181 source_proposed_plan_thread_id,
182 source_proposed_plan_id,
181183 assistant_message_id,
182184 state,
183185 requested_at,
......@@ -192,6 +194,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
192194 'thread-1',
193195 'turn-1',
194196 NULL,
197 'thread-1',
198 'plan-1',
195199 'message-1',
196200 'completed',
197201 '2026-02-24T00:00:08.000Z',
......@@ -262,6 +266,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
262266 startedAt: "2026-02-24T00:00:08.000Z",
263267 completedAt: "2026-02-24T00:00:08.000Z",
264268 assistantMessageId: asMessageId("message-1"),
269 sourceProposedPlan: {
270 threadId: ThreadId.makeUnsafe("thread-1"),
271 planId: "plan-1",
272 },
265273 },
266274 createdAt: "2026-02-24T00:00:02.000Z",
267275 updatedAt: "2026-02-24T00:00:03.000Z",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+15-1
......@@ -4,8 +4,10 @@ import {
44 MessageId,
55 NonNegativeInt,
66 OrchestrationCheckpointFile,
7 OrchestrationProposedPlanId,
78 OrchestrationReadModel,
89 ProjectScript,
10 ThreadId,
911 TurnId,
1012 type OrchestrationCheckpointSummary,
1113 type OrchestrationLatestTurn,
......@@ -74,6 +76,8 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({
7476 startedAt: Schema.NullOr(IsoDateTime),
7577 completedAt: Schema.NullOr(IsoDateTime),
7678 assistantMessageId: Schema.NullOr(MessageId),
79 sourceProposedPlanThreadId: Schema.NullOr(ThreadId),
80 sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId),
7781});
7882const ProjectionStateDbRowSchema = ProjectionState;
7983
......@@ -286,7 +290,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
286290 requested_at AS "requestedAt",
287291 started_at AS "startedAt",
288292 completed_at AS "completedAt",
289 assistant_message_id AS "assistantMessageId"
293 assistant_message_id AS "assistantMessageId",
294 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
295 source_proposed_plan_id AS "sourceProposedPlanId"
290296 FROM projection_turns
291297 WHERE turn_id IS NOT NULL
292298 ORDER BY thread_id ASC, requested_at DESC, turn_id DESC
......@@ -501,6 +507,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
501507 startedAt: row.startedAt,
502508 completedAt: row.completedAt,
503509 assistantMessageId: row.assistantMessageId,
510 ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
511 ? {
512 sourceProposedPlan: {
513 threadId: row.sourceProposedPlanThreadId,
514 planId: row.sourceProposedPlanId,
515 },
516 }
517 : {}),
504518 });
505519 }
506520
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+457-6
......@@ -83,9 +83,13 @@ describe("ProviderCommandReactor", () => {
8383 createdStateDirs.clear();
8484 });
8585
86 async function createHarness(input?: { readonly stateDir?: string }) {
86 async function createHarness(input?: {
87 readonly stateDir?: string;
88 readonly threadModel?: string;
89 }) {
8790 const now = new Date().toISOString();
8891 const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
92 const threadModel = input?.threadModel ?? "gpt-5-codex";
8993 createdStateDirs.add(stateDir);
9094 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
9195 let nextSessionIndex = 1;
......@@ -96,7 +100,7 @@ describe("ProviderCommandReactor", () => {
96100 typeof input === "object" &&
97101 input !== null &&
98102 "provider" in input &&
99 input.provider === "codex"
103 (input.provider === "codex" || input.provider === "claudeAgent")
100104 ? input.provider
101105 : "codex";
102106 const resumeCursor =
......@@ -129,7 +133,7 @@ describe("ProviderCommandReactor", () => {
129133 : "full-access",
130134 ...(model !== undefined ? { model } : {}),
131135 threadId,
132 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
136 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
133137 createdAt: now,
134138 updatedAt: now,
135139 };
......@@ -228,7 +232,7 @@ describe("ProviderCommandReactor", () => {
228232 projectId: asProjectId("project-1"),
229233 title: "Provider Project",
230234 workspaceRoot: "/tmp/provider-project",
231 defaultModel: "gpt-5-codex",
235 defaultModel: threadModel,
232236 createdAt: now,
233237 }),
234238 );
......@@ -239,7 +243,7 @@ describe("ProviderCommandReactor", () => {
239243 threadId: ThreadId.makeUnsafe("thread-1"),
240244 projectId: asProjectId("project-1"),
241245 title: "Thread",
242 model: "gpt-5-codex",
246 model: threadModel,
243247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
244248 runtimeMode: "approval-required",
245249 branch: null,
......@@ -351,6 +355,106 @@ describe("ProviderCommandReactor", () => {
351355 });
352356 });
353357
358 it("forwards claude effort options through session start and turn send", async () => {
359 const harness = await createHarness({ threadModel: "claude-sonnet-4-6" });
360 const now = new Date().toISOString();
361
362 await Effect.runPromise(
363 harness.engine.dispatch({
364 type: "thread.turn.start",
365 commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort"),
366 threadId: ThreadId.makeUnsafe("thread-1"),
367 message: {
368 messageId: asMessageId("user-message-claude-effort"),
369 role: "user",
370 text: "hello with effort",
371 attachments: [],
372 },
373 provider: "claudeAgent",
374 model: "claude-sonnet-4-6",
375 modelOptions: {
376 claudeAgent: {
377 effort: "max",
378 },
379 },
380 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
381 runtimeMode: "approval-required",
382 createdAt: now,
383 }),
384 );
385
386 await waitFor(() => harness.startSession.mock.calls.length === 1);
387 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
388 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
389 provider: "claudeAgent",
390 model: "claude-sonnet-4-6",
391 modelOptions: {
392 claudeAgent: {
393 effort: "max",
394 },
395 },
396 });
397 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
398 threadId: ThreadId.makeUnsafe("thread-1"),
399 model: "claude-sonnet-4-6",
400 modelOptions: {
401 claudeAgent: {
402 effort: "max",
403 },
404 },
405 });
406 });
407
408 it("forwards claude fast mode options through session start and turn send", async () => {
409 const harness = await createHarness({ threadModel: "claude-opus-4-6" });
410 const now = new Date().toISOString();
411
412 await Effect.runPromise(
413 harness.engine.dispatch({
414 type: "thread.turn.start",
415 commandId: CommandId.makeUnsafe("cmd-turn-start-claude-fast-mode"),
416 threadId: ThreadId.makeUnsafe("thread-1"),
417 message: {
418 messageId: asMessageId("user-message-claude-fast-mode"),
419 role: "user",
420 text: "hello with fast mode",
421 attachments: [],
422 },
423 provider: "claudeAgent",
424 model: "claude-opus-4-6",
425 modelOptions: {
426 claudeAgent: {
427 fastMode: true,
428 },
429 },
430 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
431 runtimeMode: "approval-required",
432 createdAt: now,
433 }),
434 );
435
436 await waitFor(() => harness.startSession.mock.calls.length === 1);
437 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
438 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
439 provider: "claudeAgent",
440 model: "claude-opus-4-6",
441 modelOptions: {
442 claudeAgent: {
443 fastMode: true,
444 },
445 },
446 });
447 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
448 threadId: ThreadId.makeUnsafe("thread-1"),
449 model: "claude-opus-4-6",
450 modelOptions: {
451 claudeAgent: {
452 fastMode: true,
453 },
454 },
455 });
456 });
457
354458 it("forwards plan interaction mode to the provider turn request", async () => {
355459 const harness = await createHarness();
356460 const now = new Date().toISOString();
......@@ -389,6 +493,102 @@ describe("ProviderCommandReactor", () => {
389493 });
390494 });
391495
496 it("rejects a first turn when requested provider conflicts with the thread model", async () => {
497 const harness = await createHarness();
498 const now = new Date().toISOString();
499
500 await Effect.runPromise(
501 harness.engine.dispatch({
502 type: "thread.turn.start",
503 commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"),
504 threadId: ThreadId.makeUnsafe("thread-1"),
505 message: {
506 messageId: asMessageId("user-message-provider-first"),
507 role: "user",
508 text: "hello claude",
509 attachments: [],
510 },
511 provider: "claudeAgent",
512 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
513 runtimeMode: "approval-required",
514 createdAt: now,
515 }),
516 );
517
518 await waitFor(async () => {
519 const readModel = await Effect.runPromise(harness.engine.getReadModel());
520 const thread = readModel.threads.find(
521 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
522 );
523 return (
524 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
525 false
526 );
527 });
528
529 expect(harness.startSession).not.toHaveBeenCalled();
530 expect(harness.sendTurn).not.toHaveBeenCalled();
531
532 const readModel = await Effect.runPromise(harness.engine.getReadModel());
533 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
534 expect(thread?.session).toBeNull();
535 expect(
536 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
537 ).toMatchObject({
538 summary: "Provider turn start failed",
539 payload: {
540 detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
541 },
542 });
543 });
544
545 it("rejects a turn when the requested model belongs to a different provider", async () => {
546 const harness = await createHarness();
547 const now = new Date().toISOString();
548
549 await Effect.runPromise(
550 harness.engine.dispatch({
551 type: "thread.turn.start",
552 commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"),
553 threadId: ThreadId.makeUnsafe("thread-1"),
554 message: {
555 messageId: asMessageId("user-message-model-provider-mismatch"),
556 role: "user",
557 text: "hello",
558 attachments: [],
559 },
560 model: "claude-sonnet-4-6",
561 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
562 runtimeMode: "approval-required",
563 createdAt: now,
564 }),
565 );
566
567 await waitFor(async () => {
568 const readModel = await Effect.runPromise(harness.engine.getReadModel());
569 const thread = readModel.threads.find(
570 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
571 );
572 return (
573 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
574 false
575 );
576 });
577
578 expect(harness.startSession).not.toHaveBeenCalled();
579 expect(harness.sendTurn).not.toHaveBeenCalled();
580
581 const readModel = await Effect.runPromise(harness.engine.getReadModel());
582 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
583 expect(
584 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
585 ).toMatchObject({
586 payload: {
587 detail: expect.stringContaining("does not belong to provider 'codex'"),
588 },
589 });
590 });
591
392592 it("reuses the same provider session when runtime mode is unchanged", async () => {
393593 const harness = await createHarness();
394594 const now = new Date().toISOString();
......@@ -435,6 +635,74 @@ describe("ProviderCommandReactor", () => {
435635 expect(harness.stopSession.mock.calls.length).toBe(0);
436636 });
437637
638 it("restarts claude sessions when claude effort changes", async () => {
639 const harness = await createHarness({ threadModel: "claude-sonnet-4-6" });
640 const now = new Date().toISOString();
641
642 await Effect.runPromise(
643 harness.engine.dispatch({
644 type: "thread.turn.start",
645 commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-1"),
646 threadId: ThreadId.makeUnsafe("thread-1"),
647 message: {
648 messageId: asMessageId("user-message-claude-effort-1"),
649 role: "user",
650 text: "first claude turn",
651 attachments: [],
652 },
653 provider: "claudeAgent",
654 model: "claude-sonnet-4-6",
655 modelOptions: {
656 claudeAgent: {
657 effort: "medium",
658 },
659 },
660 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
661 runtimeMode: "approval-required",
662 createdAt: now,
663 }),
664 );
665
666 await waitFor(() => harness.startSession.mock.calls.length === 1);
667 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
668
669 await Effect.runPromise(
670 harness.engine.dispatch({
671 type: "thread.turn.start",
672 commandId: CommandId.makeUnsafe("cmd-turn-start-claude-effort-2"),
673 threadId: ThreadId.makeUnsafe("thread-1"),
674 message: {
675 messageId: asMessageId("user-message-claude-effort-2"),
676 role: "user",
677 text: "second claude turn",
678 attachments: [],
679 },
680 provider: "claudeAgent",
681 model: "claude-sonnet-4-6",
682 modelOptions: {
683 claudeAgent: {
684 effort: "max",
685 },
686 },
687 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
688 runtimeMode: "approval-required",
689 createdAt: now,
690 }),
691 );
692
693 await waitFor(() => harness.startSession.mock.calls.length === 2);
694 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
695 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
696 provider: "claudeAgent",
697 resumeCursor: { opaque: "resume-1" },
698 modelOptions: {
699 claudeAgent: {
700 effort: "max",
701 },
702 },
703 });
704 });
705
438706 it("restarts the provider session when runtime mode is updated on the thread", async () => {
439707 const harness = await createHarness();
440708 const now = new Date().toISOString();
......@@ -509,7 +777,7 @@ describe("ProviderCommandReactor", () => {
509777 expect(harness.stopSession.mock.calls.length).toBe(0);
510778 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
511779 threadId: ThreadId.makeUnsafe("thread-1"),
512 resumeCursor: { opaque: "cursor-1" },
780 resumeCursor: { opaque: "resume-1" },
513781 runtimeMode: "approval-required",
514782 });
515783 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
......@@ -522,6 +790,77 @@ describe("ProviderCommandReactor", () => {
522790 expect(thread?.session?.runtimeMode).toBe("approval-required");
523791 });
524792
793 it("rejects provider changes after a thread is already bound to a session provider", async () => {
794 const harness = await createHarness();
795 const now = new Date().toISOString();
796
797 await Effect.runPromise(
798 harness.engine.dispatch({
799 type: "thread.turn.start",
800 commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"),
801 threadId: ThreadId.makeUnsafe("thread-1"),
802 message: {
803 messageId: asMessageId("user-message-provider-switch-1"),
804 role: "user",
805 text: "first",
806 attachments: [],
807 },
808 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
809 runtimeMode: "approval-required",
810 createdAt: now,
811 }),
812 );
813
814 await waitFor(() => harness.startSession.mock.calls.length === 1);
815 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
816
817 await Effect.runPromise(
818 harness.engine.dispatch({
819 type: "thread.turn.start",
820 commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"),
821 threadId: ThreadId.makeUnsafe("thread-1"),
822 message: {
823 messageId: asMessageId("user-message-provider-switch-2"),
824 role: "user",
825 text: "second",
826 attachments: [],
827 },
828 provider: "claudeAgent",
829 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
830 runtimeMode: "approval-required",
831 createdAt: now,
832 }),
833 );
834
835 await waitFor(async () => {
836 const readModel = await Effect.runPromise(harness.engine.getReadModel());
837 const thread = readModel.threads.find(
838 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
839 );
840 return (
841 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
842 false
843 );
844 });
845
846 expect(harness.startSession.mock.calls.length).toBe(1);
847 expect(harness.sendTurn.mock.calls.length).toBe(1);
848 expect(harness.stopSession.mock.calls.length).toBe(0);
849
850 const readModel = await Effect.runPromise(harness.engine.getReadModel());
851 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
852 expect(thread?.session?.threadId).toBe("thread-1");
853 expect(thread?.session?.providerName).toBe("codex");
854 expect(thread?.session?.runtimeMode).toBe("approval-required");
855 expect(
856 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
857 ).toMatchObject({
858 payload: {
859 detail: expect.stringContaining("cannot switch to 'claudeAgent'"),
860 },
861 });
862 });
863
525864 it("does not stop the active session when restart fails before rebind", async () => {
526865 const harness = await createHarness();
527866 const now = new Date().toISOString();
......@@ -797,6 +1136,7 @@ describe("ProviderCommandReactor", () => {
7971136 expect(failureActivity).toBeDefined();
7981137 expect(failureActivity?.payload).toMatchObject({
7991138 requestId: "approval-request-1",
1139 detail: expect.stringContaining("Stale pending approval request: approval-request-1"),
8001140 });
8011141
8021142 const resolvedActivity = thread?.activities.find(
......@@ -809,6 +1149,117 @@ describe("ProviderCommandReactor", () => {
8091149 expect(resolvedActivity).toBeUndefined();
8101150 });
8111151
1152 it("surfaces stale provider user-input failures without faking user-input resolution", async () => {
1153 const harness = await createHarness();
1154 const now = new Date().toISOString();
1155 harness.respondToUserInput.mockImplementation(() =>
1156 Effect.fail(
1157 new ProviderAdapterRequestError({
1158 provider: "claudeAgent",
1159 method: "item/tool/respondToUserInput",
1160 detail: "Unknown pending user-input request: user-input-request-1",
1161 }),
1162 ),
1163 );
1164
1165 await Effect.runPromise(
1166 harness.engine.dispatch({
1167 type: "thread.session.set",
1168 commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"),
1169 threadId: ThreadId.makeUnsafe("thread-1"),
1170 session: {
1171 threadId: ThreadId.makeUnsafe("thread-1"),
1172 status: "running",
1173 providerName: "claudeAgent",
1174 runtimeMode: "approval-required",
1175 activeTurnId: null,
1176 lastError: null,
1177 updatedAt: now,
1178 },
1179 createdAt: now,
1180 }),
1181 );
1182
1183 await Effect.runPromise(
1184 harness.engine.dispatch({
1185 type: "thread.activity.append",
1186 commandId: CommandId.makeUnsafe("cmd-user-input-requested"),
1187 threadId: ThreadId.makeUnsafe("thread-1"),
1188 activity: {
1189 id: EventId.makeUnsafe("activity-user-input-requested"),
1190 tone: "info",
1191 kind: "user-input.requested",
1192 summary: "User input requested",
1193 payload: {
1194 requestId: "user-input-request-1",
1195 questions: [
1196 {
1197 id: "sandbox_mode",
1198 header: "Sandbox",
1199 question: "Which mode should be used?",
1200 options: [
1201 {
1202 label: "workspace-write",
1203 description: "Allow workspace writes only",
1204 },
1205 ],
1206 },
1207 ],
1208 },
1209 turnId: null,
1210 createdAt: now,
1211 },
1212 createdAt: now,
1213 }),
1214 );
1215
1216 await Effect.runPromise(
1217 harness.engine.dispatch({
1218 type: "thread.user-input.respond",
1219 commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"),
1220 threadId: ThreadId.makeUnsafe("thread-1"),
1221 requestId: asApprovalRequestId("user-input-request-1"),
1222 answers: {
1223 sandbox_mode: "workspace-write",
1224 },
1225 createdAt: now,
1226 }),
1227 );
1228
1229 await waitFor(async () => {
1230 const readModel = await Effect.runPromise(harness.engine.getReadModel());
1231 const thread = readModel.threads.find(
1232 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
1233 );
1234 if (!thread) return false;
1235 return thread.activities.some(
1236 (activity) => activity.kind === "provider.user-input.respond.failed",
1237 );
1238 });
1239
1240 const readModel = await Effect.runPromise(harness.engine.getReadModel());
1241 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
1242 expect(thread).toBeDefined();
1243
1244 const failureActivity = thread?.activities.find(
1245 (activity) => activity.kind === "provider.user-input.respond.failed",
1246 );
1247 expect(failureActivity).toBeDefined();
1248 expect(failureActivity?.payload).toMatchObject({
1249 requestId: "user-input-request-1",
1250 detail: expect.stringContaining("Stale pending user-input request: user-input-request-1"),
1251 });
1252
1253 const resolvedActivity = thread?.activities.find(
1254 (activity) =>
1255 activity.kind === "user-input.resolved" &&
1256 typeof activity.payload === "object" &&
1257 activity.payload !== null &&
1258 (activity.payload as Record<string, unknown>).requestId === "user-input-request-1",
1259 );
1260 expect(resolvedActivity).toBeUndefined();
1261 });
1262
8121263 it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => {
8131264 const harness = await createHarness();
8141265 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+89-17
......@@ -5,7 +5,7 @@ import {
55 EventId,
66 type OrchestrationEvent,
77 type ProviderModelOptions,
8 type ProviderKind,
8 ProviderKind,
99 type ProviderStartOptions,
1010 type OrchestrationSession,
1111 ThreadId,
......@@ -26,6 +26,7 @@ import {
2626 ProviderCommandReactor,
2727 type ProviderCommandReactorShape,
2828} from "../Services/ProviderCommandReactor.ts";
29import { inferProviderForModel } from "@t3tools/shared/model";
2930
3031type ProviderIntentEvent = Extract<
3132 OrchestrationEvent,
......@@ -75,6 +76,11 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
7576const WORKTREE_BRANCH_PREFIX = "t3code";
7677const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
7778
79const sameModelOptions = (
80 left: ProviderModelOptions | undefined,
81 right: ProviderModelOptions | undefined,
82): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
83
7884function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
7985 const error = Cause.squash(cause);
8086 if (Schema.is(ProviderAdapterRequestError)(error)) {
......@@ -91,6 +97,21 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService
9197 );
9298}
9399
100function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
101 const error = Cause.squash(cause);
102 if (Schema.is(ProviderAdapterRequestError)(error)) {
103 return error.detail.toLowerCase().includes("unknown pending user-input request");
104 }
105 return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");
106}
107
108function stalePendingRequestDetail(
109 requestKind: "approval" | "user-input",
110 requestId: string,
111): string {
112 return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
113}
114
94115function isTemporaryWorktreeBranch(branch: string): boolean {
95116 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
96117}
......@@ -137,6 +158,7 @@ const make = Effect.gen(function* () {
137158 );
138159
139160 const threadProviderOptions = new Map<string, ProviderStartOptions>();
161 const threadModelOptions = new Map<string, ProviderModelOptions>();
140162
141163 const appendProviderFailureActivity = (input: {
142164 readonly threadId: ThreadId;
......@@ -206,9 +228,30 @@ const make = Effect.gen(function* () {
206228 }
207229
208230 const desiredRuntimeMode = thread.runtimeMode;
209 const currentProvider: ProviderKind | undefined =
210 thread.session?.providerName === "codex" ? thread.session.providerName : undefined;
211 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
231 const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)(
232 thread.session?.providerName,
233 )
234 ? thread.session.providerName
235 : undefined;
236 const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model);
237 if (options?.provider !== undefined && options.provider !== threadProvider) {
238 return yield* new ProviderAdapterRequestError({
239 provider: threadProvider,
240 method: "thread.turn.start",
241 detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`,
242 });
243 }
244 if (
245 options?.model !== undefined &&
246 inferProviderForModel(options.model, threadProvider) !== threadProvider
247 ) {
248 return yield* new ProviderAdapterRequestError({
249 provider: threadProvider,
250 method: "thread.turn.start",
251 detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`,
252 });
253 }
254 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
212255 const desiredModel = options?.model ?? thread.model;
213256 const effectiveCwd = resolveThreadWorkspaceCwd({
214257 thread,
......@@ -268,8 +311,18 @@ const make = Effect.gen(function* () {
268311 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
269312 const modelChanged = options?.model !== undefined && options.model !== activeSession?.model;
270313 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
314 const previousModelOptions = threadModelOptions.get(threadId);
315 const shouldRestartForModelOptionsChange =
316 currentProvider === "claudeAgent" &&
317 options?.modelOptions !== undefined &&
318 !sameModelOptions(previousModelOptions, options.modelOptions);
271319
272 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {
320 if (
321 !runtimeModeChanged &&
322 !providerChanged &&
323 !shouldRestartForModelChange &&
324 !shouldRestartForModelOptionsChange
325 ) {
273326 return existingSessionThreadId;
274327 }
275328
......@@ -288,6 +341,7 @@ const make = Effect.gen(function* () {
288341 providerChanged,
289342 modelChanged,
290343 shouldRestartForModelChange,
344 shouldRestartForModelOptionsChange,
291345 hasResumeCursor: resumeCursor !== undefined,
292346 });
293347 const restartedSession = yield* startProviderSession({
......@@ -327,15 +381,18 @@ const make = Effect.gen(function* () {
327381 if (!thread) {
328382 return;
329383 }
330 if (input.providerOptions !== undefined) {
331 threadProviderOptions.set(input.threadId, input.providerOptions);
332 }
333384 yield* ensureSessionForThread(input.threadId, input.createdAt, {
334385 ...(input.provider !== undefined ? { provider: input.provider } : {}),
335386 ...(input.model !== undefined ? { model: input.model } : {}),
336387 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
337388 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
338389 });
390 if (input.providerOptions !== undefined) {
391 threadProviderOptions.set(input.threadId, input.providerOptions);
392 }
393 if (input.modelOptions !== undefined) {
394 threadModelOptions.set(input.threadId, input.modelOptions);
395 }
339396 const normalizedInput = toNonEmptyProviderInput(input.messageText);
340397 const normalizedAttachments = input.attachments ?? [];
341398 const activeSession = yield* providerService
......@@ -477,7 +534,18 @@ const make = Effect.gen(function* () {
477534 : {}),
478535 interactionMode: event.payload.interactionMode,
479536 createdAt: event.payload.createdAt,
480 });
537 }).pipe(
538 Effect.catchCause((cause) =>
539 appendProviderFailureActivity({
540 threadId: event.payload.threadId,
541 kind: "provider.turn.start.failed",
542 summary: "Provider turn start failed",
543 detail: Cause.pretty(cause),
544 turnId: null,
545 createdAt: event.payload.createdAt,
546 }),
547 ),
548 );
481549 });
482550
483551 const processTurnInterruptRequested = Effect.fnUntraced(function* (
......@@ -536,7 +604,9 @@ const make = Effect.gen(function* () {
536604 threadId: event.payload.threadId,
537605 kind: "provider.approval.respond.failed",
538606 summary: "Provider approval response failed",
539 detail: Cause.pretty(cause),
607 detail: isUnknownPendingApprovalRequestError(cause)
608 ? stalePendingRequestDetail("approval", event.payload.requestId)
609 : Cause.pretty(cause),
540610 turnId: null,
541611 createdAt: event.payload.createdAt,
542612 requestId: event.payload.requestId,
......@@ -580,7 +650,9 @@ const make = Effect.gen(function* () {
580650 threadId: event.payload.threadId,
581651 kind: "provider.user-input.respond.failed",
582652 summary: "Provider user input response failed",
583 detail: Cause.pretty(cause),
653 detail: isUnknownPendingUserInputRequestError(cause)
654 ? stalePendingRequestDetail("user-input", event.payload.requestId)
655 : Cause.pretty(cause),
584656 turnId: null,
585657 createdAt: event.payload.createdAt,
586658 requestId: event.payload.requestId,
......@@ -626,13 +698,13 @@ const make = Effect.gen(function* () {
626698 return;
627699 }
628700 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
629 yield* ensureSessionForThread(
630 event.payload.threadId,
631 event.occurredAt,
632 cachedProviderOptions !== undefined
701 const cachedModelOptions = threadModelOptions.get(event.payload.threadId);
702 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
703 ...(cachedProviderOptions !== undefined
633704 ? { providerOptions: cachedProviderOptions }
634 : undefined,
635 );
705 : {}),
706 ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),
707 });
636708 return;
637709 }
638710 case "thread.turn-start-requested":
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+60-3
......@@ -4,6 +4,7 @@ import path from "node:path";
44
55import type {
66 OrchestrationReadModel,
7 ProviderKind,
78 ProviderRuntimeEvent,
89 ProviderSession,
910} from "@t3tools/contracts";
......@@ -49,7 +50,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4950type LegacyProviderRuntimeEvent = {
5051 readonly type: string;
5152 readonly eventId: EventId;
52 readonly provider: "codex";
53 readonly provider: ProviderKind;
5354 readonly createdAt: string;
5455 readonly threadId: ThreadId;
5556 readonly turnId?: string | undefined;
......@@ -428,6 +429,60 @@ describe("ProviderRuntimeIngestion", () => {
428429 );
429430 });
430431
432 it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => {
433 const harness = await createHarness();
434 const seededAt = new Date().toISOString();
435
436 await Effect.runPromise(
437 harness.engine.dispatch({
438 type: "thread.session.set",
439 commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"),
440 threadId: ThreadId.makeUnsafe("thread-1"),
441 session: {
442 threadId: ThreadId.makeUnsafe("thread-1"),
443 status: "ready",
444 providerName: "claudeAgent",
445 runtimeMode: "approval-required",
446 activeTurnId: null,
447 updatedAt: seededAt,
448 lastError: null,
449 },
450 createdAt: seededAt,
451 }),
452 );
453
454 harness.emit({
455 type: "turn.started",
456 eventId: asEventId("evt-turn-started-claude-placeholder"),
457 provider: "claudeAgent",
458 createdAt: new Date().toISOString(),
459 threadId: asThreadId("thread-1"),
460 turnId: asTurnId("turn-claude-placeholder"),
461 });
462
463 await waitForThread(
464 harness.engine,
465 (thread) =>
466 thread.session?.status === "running" &&
467 thread.session?.activeTurnId === "turn-claude-placeholder",
468 );
469
470 harness.emit({
471 type: "turn.completed",
472 eventId: asEventId("evt-turn-completed-claude-placeholder"),
473 provider: "claudeAgent",
474 createdAt: new Date().toISOString(),
475 threadId: asThreadId("thread-1"),
476 turnId: asTurnId("turn-claude-placeholder"),
477 status: "completed",
478 });
479
480 await waitForThread(
481 harness.engine,
482 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
483 );
484 });
485
431486 it("ignores auxiliary turn completions from a different provider thread", async () => {
432487 const harness = await createHarness();
433488 const now = new Date().toISOString();
......@@ -1870,6 +1925,7 @@ describe("ProviderRuntimeIngestion", () => {
18701925 payload: {
18711926 taskId: "turn-task-1",
18721927 description: "Comparing the desktop rollout chunks to the app-server stream.",
1928 summary: "Code reviewer is validating the desktop rollout chunks.",
18731929 },
18741930 });
18751931
......@@ -1932,8 +1988,9 @@ describe("ProviderRuntimeIngestion", () => {
19321988 expect(started?.kind).toBe("task.started");
19331989 expect(started?.summary).toBe("Plan task started");
19341990 expect(progress?.kind).toBe("task.progress");
1935 expect(progressPayload?.detail).toBe(
1936 "Comparing the desktop rollout chunks to the app-server stream.",
1991 expect(progressPayload?.detail).toBe("Code reviewer is validating the desktop rollout chunks.");
1992 expect(progressPayload?.summary).toBe(
1993 "Code reviewer is validating the desktop rollout chunks.",
19371994 );
19381995 expect(completed?.kind).toBe("task.completed");
19391996 expect(completedPayload?.detail).toBe("<proposed_plan>\n# Plan title\n</proposed_plan>");
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+2-1
......@@ -373,7 +373,8 @@ function runtimeEventToActivities(
373373 summary: "Reasoning update",
374374 payload: {
375375 taskId: event.payload.taskId,
376 detail: truncateDetail(event.payload.description),
376 detail: truncateDetail(event.payload.summary ?? event.payload.description),
377 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
377378 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
378379 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
379380 },
apps/server/src/persistence/Layers/ProjectionTurns.ts+10
......@@ -47,6 +47,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
4747 thread_id,
4848 turn_id,
4949 pending_message_id,
50 source_proposed_plan_thread_id,
51 source_proposed_plan_id,
5052 assistant_message_id,
5153 state,
5254 requested_at,
......@@ -61,6 +63,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
6163 ${row.threadId},
6264 ${row.turnId},
6365 ${row.pendingMessageId},
66 ${row.sourceProposedPlanThreadId},
67 ${row.sourceProposedPlanId},
6468 ${row.assistantMessageId},
6569 ${row.state},
6670 ${row.requestedAt},
......@@ -74,6 +78,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
7478 ON CONFLICT (thread_id, turn_id)
7579 DO UPDATE SET
7680 pending_message_id = excluded.pending_message_id,
81 source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id,
82 source_proposed_plan_id = excluded.source_proposed_plan_id,
7783 assistant_message_id = excluded.assistant_message_id,
7884 state = excluded.state,
7985 requested_at = excluded.requested_at,
......@@ -168,6 +174,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
168174 thread_id AS "threadId",
169175 turn_id AS "turnId",
170176 pending_message_id AS "pendingMessageId",
177 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
178 source_proposed_plan_id AS "sourceProposedPlanId",
171179 assistant_message_id AS "assistantMessageId",
172180 state,
173181 requested_at AS "requestedAt",
......@@ -199,6 +207,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () {
199207 thread_id AS "threadId",
200208 turn_id AS "turnId",
201209 pending_message_id AS "pendingMessageId",
210 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
211 source_proposed_plan_id AS "sourceProposedPlanId",
202212 assistant_message_id AS "assistantMessageId",
203213 state,
204214 requested_at AS "requestedAt",
apps/server/src/persistence/Services/ProjectionTurns.ts+4
......@@ -35,6 +35,8 @@ export const ProjectionTurn = Schema.Struct({
3535 threadId: ThreadId,
3636 turnId: Schema.NullOr(TurnId),
3737 pendingMessageId: Schema.NullOr(MessageId),
38 sourceProposedPlanThreadId: Schema.NullOr(ThreadId),
39 sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId),
3840 assistantMessageId: Schema.NullOr(MessageId),
3941 state: ProjectionTurnState,
4042 requestedAt: IsoDateTime,
......@@ -51,6 +53,8 @@ export const ProjectionTurnById = Schema.Struct({
5153 threadId: ThreadId,
5254 turnId: TurnId,
5355 pendingMessageId: Schema.NullOr(MessageId),
56 sourceProposedPlanThreadId: Schema.NullOr(ThreadId),
57 sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId),
5458 assistantMessageId: Schema.NullOr(MessageId),
5559 state: ProjectionTurnState,
5660 requestedAt: IsoDateTime,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts created+2763
......@@ -0,0 +1,2763 @@
1import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import type {
7 Options as ClaudeQueryOptions,
8 PermissionMode,
9 PermissionResult,
10 SDKMessage,
11 SDKUserMessage,
12} from "@anthropic-ai/claude-agent-sdk";
13import {
14 ApprovalRequestId,
15 ProviderItemId,
16 ProviderRuntimeEvent,
17 ThreadId,
18} from "@t3tools/contracts";
19import { assert, describe, it } from "@effect/vitest";
20import { Effect, Fiber, Layer, Random, Stream } from "effect";
21
22import { attachmentRelativePath } from "../../attachmentStore.ts";
23import { ServerConfig } from "../../config.ts";
24import { ProviderAdapterValidationError } from "../Errors.ts";
25import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
26import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
27
28class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
29 private readonly queue: Array<SDKMessage> = [];
30 private readonly waiters: Array<{
31 readonly resolve: (value: IteratorResult<SDKMessage>) => void;
32 readonly reject: (reason: unknown) => void;
33 }> = [];
34 private done = false;
35 private failure: unknown | undefined;
36
37 public readonly interruptCalls: Array<void> = [];
38 public readonly setModelCalls: Array<string | undefined> = [];
39 public readonly setPermissionModeCalls: Array<string> = [];
40 public readonly setMaxThinkingTokensCalls: Array<number | null> = [];
41 public closeCalls = 0;
42
43 emit(message: SDKMessage): void {
44 if (this.done) {
45 return;
46 }
47 const waiter = this.waiters.shift();
48 if (waiter) {
49 waiter.resolve({ done: false, value: message });
50 return;
51 }
52 this.queue.push(message);
53 }
54
55 fail(cause: unknown): void {
56 if (this.done) {
57 return;
58 }
59 this.done = true;
60 this.failure = cause;
61 for (const waiter of this.waiters.splice(0)) {
62 waiter.reject(cause);
63 }
64 }
65
66 finish(): void {
67 if (this.done) {
68 return;
69 }
70 this.done = true;
71 this.failure = undefined;
72 for (const waiter of this.waiters.splice(0)) {
73 waiter.resolve({ done: true, value: undefined });
74 }
75 }
76
77 readonly interrupt = async (): Promise<void> => {
78 this.interruptCalls.push(undefined);
79 };
80
81 readonly setModel = async (model?: string): Promise<void> => {
82 this.setModelCalls.push(model);
83 };
84
85 readonly setPermissionMode = async (mode: PermissionMode): Promise<void> => {
86 this.setPermissionModeCalls.push(mode);
87 };
88
89 readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise<void> => {
90 this.setMaxThinkingTokensCalls.push(maxThinkingTokens);
91 };
92
93 readonly close = (): void => {
94 this.closeCalls += 1;
95 this.finish();
96 };
97
98 [Symbol.asyncIterator](): AsyncIterator<SDKMessage> {
99 return {
100 next: () => {
101 if (this.queue.length > 0) {
102 const value = this.queue.shift();
103 if (value) {
104 return Promise.resolve({
105 done: false,
106 value,
107 });
108 }
109 }
110 if (this.failure !== undefined) {
111 const failure = this.failure;
112 this.failure = undefined;
113 return Promise.reject(failure);
114 }
115 if (this.done) {
116 return Promise.resolve({
117 done: true,
118 value: undefined,
119 });
120 }
121 return new Promise((resolve, reject) => {
122 this.waiters.push({
123 resolve,
124 reject,
125 });
126 });
127 },
128 };
129 }
130}
131
132function makeHarness(config?: {
133 readonly nativeEventLogPath?: string;
134 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
135 readonly cwd?: string;
136 readonly stateDir?: string;
137}) {
138 const query = new FakeClaudeQuery();
139 let createInput:
140 | {
141 readonly prompt: AsyncIterable<SDKUserMessage>;
142 readonly options: ClaudeQueryOptions;
143 }
144 | undefined;
145
146 const adapterOptions: ClaudeAdapterLiveOptions = {
147 createQuery: (input) => {
148 createInput = input;
149 return query;
150 },
151 ...(config?.nativeEventLogger
152 ? {
153 nativeEventLogger: config.nativeEventLogger,
154 }
155 : {}),
156 ...(config?.nativeEventLogPath
157 ? {
158 nativeEventLogPath: config.nativeEventLogPath,
159 }
160 : {}),
161 };
162
163 return {
164 layer: makeClaudeAdapterLive(adapterOptions).pipe(
165 Layer.provideMerge(
166 ServerConfig.layerTest(
167 config?.cwd ?? "/tmp/claude-adapter-test",
168 config?.stateDir ?? "/tmp",
169 ),
170 ),
171 Layer.provideMerge(NodeServices.layer),
172 ),
173 query,
174 getLastCreateQueryInput: () => createInput,
175 };
176}
177
178function makeDeterministicRandomService(seed = 0x1234_5678): {
179 nextIntUnsafe: () => number;
180 nextDoubleUnsafe: () => number;
181} {
182 let state = seed >>> 0;
183 const nextIntUnsafe = (): number => {
184 state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0;
185 return state;
186 };
187
188 return {
189 nextIntUnsafe,
190 nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000,
191 };
192}
193
194async function readFirstPromptText(
195 input:
196 | {
197 readonly prompt: AsyncIterable<SDKUserMessage>;
198 }
199 | undefined,
200): Promise<string | undefined> {
201 const iterator = input?.prompt[Symbol.asyncIterator]();
202 if (!iterator) {
203 return undefined;
204 }
205 const next = await iterator.next();
206 if (next.done) {
207 return undefined;
208 }
209 const content = next.value.message.content[0];
210 if (!content || content.type !== "text") {
211 return undefined;
212 }
213 return content.text;
214}
215
216async function readFirstPromptMessage(
217 input:
218 | {
219 readonly prompt: AsyncIterable<SDKUserMessage>;
220 }
221 | undefined,
222): Promise<SDKUserMessage | undefined> {
223 const iterator = input?.prompt[Symbol.asyncIterator]();
224 if (!iterator) {
225 return undefined;
226 }
227 const next = await iterator.next();
228 if (next.done) {
229 return undefined;
230 }
231 return next.value;
232}
233
234const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
235const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
236
237describe("ClaudeAdapterLive", () => {
238 it.effect("returns validation error for non-claude provider on startSession", () => {
239 const harness = makeHarness();
240 return Effect.gen(function* () {
241 const adapter = yield* ClaudeAdapter;
242 const result = yield* adapter
243 .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" })
244 .pipe(Effect.result);
245
246 assert.equal(result._tag, "Failure");
247 if (result._tag !== "Failure") {
248 return;
249 }
250 assert.deepEqual(
251 result.failure,
252 new ProviderAdapterValidationError({
253 provider: "claudeAgent",
254 operation: "startSession",
255 issue: "Expected provider 'claudeAgent' but received 'codex'.",
256 }),
257 );
258 }).pipe(
259 Effect.provideService(Random.Random, makeDeterministicRandomService()),
260 Effect.provide(harness.layer),
261 );
262 });
263
264 it.effect("derives bypass permission mode from full-access runtime policy", () => {
265 const harness = makeHarness();
266 return Effect.gen(function* () {
267 const adapter = yield* ClaudeAdapter;
268 yield* adapter.startSession({
269 threadId: THREAD_ID,
270 provider: "claudeAgent",
271 runtimeMode: "full-access",
272 });
273
274 const createInput = harness.getLastCreateQueryInput();
275 assert.equal(createInput?.options.permissionMode, "bypassPermissions");
276 assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
277 }).pipe(
278 Effect.provideService(Random.Random, makeDeterministicRandomService()),
279 Effect.provide(harness.layer),
280 );
281 });
282
283 it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => {
284 const harness = makeHarness();
285 return Effect.gen(function* () {
286 const adapter = yield* ClaudeAdapter;
287 yield* adapter.startSession({
288 threadId: THREAD_ID,
289 provider: "claudeAgent",
290 runtimeMode: "full-access",
291 providerOptions: {
292 claudeAgent: {
293 permissionMode: "plan",
294 },
295 },
296 });
297
298 const createInput = harness.getLastCreateQueryInput();
299 assert.equal(createInput?.options.permissionMode, "plan");
300 assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);
301 }).pipe(
302 Effect.provideService(Random.Random, makeDeterministicRandomService()),
303 Effect.provide(harness.layer),
304 );
305 });
306
307 it.effect("forwards claude effort levels into query options", () => {
308 const harness = makeHarness();
309 return Effect.gen(function* () {
310 const adapter = yield* ClaudeAdapter;
311 yield* adapter.startSession({
312 threadId: THREAD_ID,
313 provider: "claudeAgent",
314 model: "claude-opus-4-6",
315 runtimeMode: "full-access",
316 modelOptions: {
317 claudeAgent: {
318 effort: "max",
319 },
320 },
321 });
322
323 const createInput = harness.getLastCreateQueryInput();
324 assert.equal(createInput?.options.effort, "max");
325 }).pipe(
326 Effect.provideService(Random.Random, makeDeterministicRandomService()),
327 Effect.provide(harness.layer),
328 );
329 });
330
331 it.effect("ignores unsupported max effort for Sonnet 4.6", () => {
332 const harness = makeHarness();
333 return Effect.gen(function* () {
334 const adapter = yield* ClaudeAdapter;
335 yield* adapter.startSession({
336 threadId: THREAD_ID,
337 provider: "claudeAgent",
338 model: "claude-sonnet-4-6",
339 runtimeMode: "full-access",
340 modelOptions: {
341 claudeAgent: {
342 effort: "max",
343 },
344 },
345 });
346
347 const createInput = harness.getLastCreateQueryInput();
348 assert.equal(createInput?.options.effort, undefined);
349 }).pipe(
350 Effect.provideService(Random.Random, makeDeterministicRandomService()),
351 Effect.provide(harness.layer),
352 );
353 });
354
355 it.effect("ignores adaptive effort for Haiku 4.5", () => {
356 const harness = makeHarness();
357 return Effect.gen(function* () {
358 const adapter = yield* ClaudeAdapter;
359 yield* adapter.startSession({
360 threadId: THREAD_ID,
361 provider: "claudeAgent",
362 model: "claude-haiku-4-5",
363 runtimeMode: "full-access",
364 modelOptions: {
365 claudeAgent: {
366 effort: "high",
367 },
368 },
369 });
370
371 const createInput = harness.getLastCreateQueryInput();
372 assert.equal(createInput?.options.effort, undefined);
373 }).pipe(
374 Effect.provideService(Random.Random, makeDeterministicRandomService()),
375 Effect.provide(harness.layer),
376 );
377 });
378
379 it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => {
380 const harness = makeHarness();
381 return Effect.gen(function* () {
382 const adapter = yield* ClaudeAdapter;
383 yield* adapter.startSession({
384 threadId: THREAD_ID,
385 provider: "claudeAgent",
386 model: "claude-haiku-4-5",
387 runtimeMode: "full-access",
388 modelOptions: {
389 claudeAgent: {
390 thinking: false,
391 },
392 },
393 });
394
395 const createInput = harness.getLastCreateQueryInput();
396 assert.deepEqual(createInput?.options.settings, {
397 alwaysThinkingEnabled: false,
398 });
399 }).pipe(
400 Effect.provideService(Random.Random, makeDeterministicRandomService()),
401 Effect.provide(harness.layer),
402 );
403 });
404
405 it.effect("ignores Claude thinking toggle for non-Haiku models", () => {
406 const harness = makeHarness();
407 return Effect.gen(function* () {
408 const adapter = yield* ClaudeAdapter;
409 yield* adapter.startSession({
410 threadId: THREAD_ID,
411 provider: "claudeAgent",
412 model: "claude-sonnet-4-6",
413 runtimeMode: "full-access",
414 modelOptions: {
415 claudeAgent: {
416 thinking: false,
417 },
418 },
419 });
420
421 const createInput = harness.getLastCreateQueryInput();
422 assert.equal(createInput?.options.settings, undefined);
423 }).pipe(
424 Effect.provideService(Random.Random, makeDeterministicRandomService()),
425 Effect.provide(harness.layer),
426 );
427 });
428
429 it.effect("forwards claude fast mode into SDK settings", () => {
430 const harness = makeHarness();
431 return Effect.gen(function* () {
432 const adapter = yield* ClaudeAdapter;
433 yield* adapter.startSession({
434 threadId: THREAD_ID,
435 provider: "claudeAgent",
436 model: "claude-opus-4-6",
437 runtimeMode: "full-access",
438 modelOptions: {
439 claudeAgent: {
440 fastMode: true,
441 },
442 },
443 });
444
445 const createInput = harness.getLastCreateQueryInput();
446 assert.deepEqual(createInput?.options.settings, {
447 fastMode: true,
448 });
449 }).pipe(
450 Effect.provideService(Random.Random, makeDeterministicRandomService()),
451 Effect.provide(harness.layer),
452 );
453 });
454
455 it.effect("ignores claude fast mode for non-opus models", () => {
456 const harness = makeHarness();
457 return Effect.gen(function* () {
458 const adapter = yield* ClaudeAdapter;
459 yield* adapter.startSession({
460 threadId: THREAD_ID,
461 provider: "claudeAgent",
462 model: "claude-sonnet-4-6",
463 runtimeMode: "full-access",
464 modelOptions: {
465 claudeAgent: {
466 fastMode: true,
467 },
468 },
469 });
470
471 const createInput = harness.getLastCreateQueryInput();
472 assert.equal(createInput?.options.settings, undefined);
473 }).pipe(
474 Effect.provideService(Random.Random, makeDeterministicRandomService()),
475 Effect.provide(harness.layer),
476 );
477 });
478
479 it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {
480 const harness = makeHarness();
481 return Effect.gen(function* () {
482 const adapter = yield* ClaudeAdapter;
483 const session = yield* adapter.startSession({
484 threadId: THREAD_ID,
485 provider: "claudeAgent",
486 model: "claude-sonnet-4-6",
487 runtimeMode: "full-access",
488 modelOptions: {
489 claudeAgent: {
490 effort: "ultrathink",
491 },
492 },
493 });
494
495 yield* adapter.sendTurn({
496 threadId: session.threadId,
497 input: "Investigate the edge cases",
498 attachments: [],
499 model: "claude-sonnet-4-6",
500 modelOptions: {
501 claudeAgent: {
502 effort: "ultrathink",
503 },
504 },
505 });
506
507 const createInput = harness.getLastCreateQueryInput();
508 assert.equal(createInput?.options.effort, undefined);
509 const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));
510 assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");
511 }).pipe(
512 Effect.provideService(Random.Random, makeDeterministicRandomService()),
513 Effect.provide(harness.layer),
514 );
515 });
516
517 it.effect("embeds image attachments in Claude user messages", () => {
518 const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-"));
519 const harness = makeHarness({
520 cwd: "/tmp/project-claude-attachments",
521 stateDir,
522 });
523 return Effect.gen(function* () {
524 yield* Effect.addFinalizer(() =>
525 Effect.sync(() =>
526 rmSync(stateDir, {
527 recursive: true,
528 force: true,
529 }),
530 ),
531 );
532
533 const adapter = yield* ClaudeAdapter;
534
535 const attachment = {
536 type: "image" as const,
537 id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc",
538 name: "diagram.png",
539 mimeType: "image/png",
540 sizeBytes: 4,
541 };
542 const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment));
543 mkdirSync(path.dirname(attachmentPath), { recursive: true });
544 writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
545
546 const session = yield* adapter.startSession({
547 threadId: THREAD_ID,
548 provider: "claudeAgent",
549 runtimeMode: "full-access",
550 });
551
552 yield* adapter.sendTurn({
553 threadId: session.threadId,
554 input: "What's in this image?",
555 attachments: [attachment],
556 });
557
558 const createInput = harness.getLastCreateQueryInput();
559 const promptMessage = yield* Effect.promise(() => readFirstPromptMessage(createInput));
560 assert.isDefined(promptMessage);
561 assert.deepEqual(promptMessage?.message.content, [
562 {
563 type: "text",
564 text: "What's in this image?",
565 },
566 {
567 type: "image",
568 source: {
569 type: "base64",
570 media_type: "image/png",
571 data: "AQIDBA==",
572 },
573 },
574 ]);
575 }).pipe(
576 Effect.provideService(Random.Random, makeDeterministicRandomService()),
577 Effect.provide(harness.layer),
578 );
579 });
580
581 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
582 const harness = makeHarness();
583 return Effect.gen(function* () {
584 const adapter = yield* ClaudeAdapter;
585
586 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe(
587 Stream.runCollect,
588 Effect.forkChild,
589 );
590
591 const session = yield* adapter.startSession({
592 threadId: THREAD_ID,
593 provider: "claudeAgent",
594 model: "claude-sonnet-4-5",
595 runtimeMode: "full-access",
596 });
597
598 const turn = yield* adapter.sendTurn({
599 threadId: session.threadId,
600 input: "hello",
601 attachments: [],
602 });
603
604 harness.query.emit({
605 type: "stream_event",
606 session_id: "sdk-session-1",
607 uuid: "stream-0",
608 parent_tool_use_id: null,
609 event: {
610 type: "content_block_start",
611 index: 0,
612 content_block: {
613 type: "text",
614 text: "",
615 },
616 },
617 } as unknown as SDKMessage);
618
619 harness.query.emit({
620 type: "stream_event",
621 session_id: "sdk-session-1",
622 uuid: "stream-1",
623 parent_tool_use_id: null,
624 event: {
625 type: "content_block_delta",
626 index: 0,
627 delta: {
628 type: "text_delta",
629 text: "Hi",
630 },
631 },
632 } as unknown as SDKMessage);
633
634 harness.query.emit({
635 type: "stream_event",
636 session_id: "sdk-session-1",
637 uuid: "stream-2",
638 parent_tool_use_id: null,
639 event: {
640 type: "content_block_stop",
641 index: 0,
642 },
643 } as unknown as SDKMessage);
644
645 harness.query.emit({
646 type: "stream_event",
647 session_id: "sdk-session-1",
648 uuid: "stream-3",
649 parent_tool_use_id: null,
650 event: {
651 type: "content_block_start",
652 index: 1,
653 content_block: {
654 type: "tool_use",
655 id: "tool-1",
656 name: "Bash",
657 input: {
658 command: "ls",
659 },
660 },
661 },
662 } as unknown as SDKMessage);
663
664 harness.query.emit({
665 type: "stream_event",
666 session_id: "sdk-session-1",
667 uuid: "stream-4",
668 parent_tool_use_id: null,
669 event: {
670 type: "content_block_stop",
671 index: 1,
672 },
673 } as unknown as SDKMessage);
674
675 harness.query.emit({
676 type: "assistant",
677 session_id: "sdk-session-1",
678 uuid: "assistant-1",
679 parent_tool_use_id: null,
680 message: {
681 id: "assistant-message-1",
682 content: [{ type: "text", text: "Hi" }],
683 },
684 } as unknown as SDKMessage);
685
686 harness.query.emit({
687 type: "result",
688 subtype: "success",
689 is_error: false,
690 errors: [],
691 session_id: "sdk-session-1",
692 uuid: "result-1",
693 } as unknown as SDKMessage);
694
695 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
696 assert.deepEqual(
697 runtimeEvents.map((event) => event.type),
698 [
699 "session.started",
700 "session.configured",
701 "session.state.changed",
702 "turn.started",
703 "thread.started",
704 "content.delta",
705 "item.completed",
706 "item.started",
707 "item.completed",
708 "turn.completed",
709 ],
710 );
711
712 const turnStarted = runtimeEvents[3];
713 assert.equal(turnStarted?.type, "turn.started");
714 if (turnStarted?.type === "turn.started") {
715 assert.equal(String(turnStarted.turnId), String(turn.turnId));
716 }
717
718 const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta");
719 assert.equal(deltaEvent?.type, "content.delta");
720 if (deltaEvent?.type === "content.delta") {
721 assert.equal(deltaEvent.payload.delta, "Hi");
722 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
723 }
724
725 const toolStarted = runtimeEvents.find((event) => event.type === "item.started");
726 assert.equal(toolStarted?.type, "item.started");
727 if (toolStarted?.type === "item.started") {
728 assert.equal(toolStarted.payload.itemType, "command_execution");
729 }
730
731 const assistantCompletedIndex = runtimeEvents.findIndex(
732 (event) =>
733 event.type === "item.completed" && event.payload.itemType === "assistant_message",
734 );
735 const toolStartedIndex = runtimeEvents.findIndex((event) => event.type === "item.started");
736 assert.equal(
737 assistantCompletedIndex >= 0 &&
738 toolStartedIndex >= 0 &&
739 assistantCompletedIndex < toolStartedIndex,
740 true,
741 );
742
743 const turnCompleted = runtimeEvents[runtimeEvents.length - 1];
744 assert.equal(turnCompleted?.type, "turn.completed");
745 if (turnCompleted?.type === "turn.completed") {
746 assert.equal(String(turnCompleted.turnId), String(turn.turnId));
747 assert.equal(turnCompleted.payload.state, "completed");
748 }
749 }).pipe(
750 Effect.provideService(Random.Random, makeDeterministicRandomService()),
751 Effect.provide(harness.layer),
752 );
753 });
754
755 it.effect("maps Claude reasoning deltas, streamed tool inputs, and tool results", () => {
756 const harness = makeHarness();
757 return Effect.gen(function* () {
758 const adapter = yield* ClaudeAdapter;
759
760 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe(
761 Stream.runCollect,
762 Effect.forkChild,
763 );
764
765 const session = yield* adapter.startSession({
766 threadId: THREAD_ID,
767 provider: "claudeAgent",
768 runtimeMode: "full-access",
769 });
770
771 const turn = yield* adapter.sendTurn({
772 threadId: session.threadId,
773 input: "hello",
774 attachments: [],
775 });
776
777 harness.query.emit({
778 type: "stream_event",
779 session_id: "sdk-session-tool-streams",
780 uuid: "stream-thinking",
781 parent_tool_use_id: null,
782 event: {
783 type: "content_block_delta",
784 index: 0,
785 delta: {
786 type: "thinking_delta",
787 thinking: "Let",
788 },
789 },
790 } as unknown as SDKMessage);
791
792 harness.query.emit({
793 type: "stream_event",
794 session_id: "sdk-session-tool-streams",
795 uuid: "stream-tool-start",
796 parent_tool_use_id: null,
797 event: {
798 type: "content_block_start",
799 index: 1,
800 content_block: {
801 type: "tool_use",
802 id: "tool-grep-1",
803 name: "Grep",
804 input: {},
805 },
806 },
807 } as unknown as SDKMessage);
808
809 harness.query.emit({
810 type: "stream_event",
811 session_id: "sdk-session-tool-streams",
812 uuid: "stream-tool-input-1",
813 parent_tool_use_id: null,
814 event: {
815 type: "content_block_delta",
816 index: 1,
817 delta: {
818 type: "input_json_delta",
819 partial_json: '{"pattern":"foo","path":"src"}',
820 },
821 },
822 } as unknown as SDKMessage);
823
824 harness.query.emit({
825 type: "stream_event",
826 session_id: "sdk-session-tool-streams",
827 uuid: "stream-tool-stop",
828 parent_tool_use_id: null,
829 event: {
830 type: "content_block_stop",
831 index: 1,
832 },
833 } as unknown as SDKMessage);
834
835 harness.query.emit({
836 type: "user",
837 session_id: "sdk-session-tool-streams",
838 uuid: "user-tool-result",
839 parent_tool_use_id: null,
840 message: {
841 role: "user",
842 content: [
843 {
844 type: "tool_result",
845 tool_use_id: "tool-grep-1",
846 content: "src/example.ts:1:foo",
847 },
848 ],
849 },
850 } as unknown as SDKMessage);
851
852 harness.query.emit({
853 type: "result",
854 subtype: "success",
855 is_error: false,
856 errors: [],
857 session_id: "sdk-session-tool-streams",
858 uuid: "result-tool-streams",
859 } as unknown as SDKMessage);
860
861 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
862 assert.deepEqual(
863 runtimeEvents.map((event) => event.type),
864 [
865 "session.started",
866 "session.configured",
867 "session.state.changed",
868 "turn.started",
869 "thread.started",
870 "content.delta",
871 "item.started",
872 "item.updated",
873 "item.updated",
874 "item.completed",
875 "turn.completed",
876 ],
877 );
878
879 const reasoningDelta = runtimeEvents.find(
880 (event) => event.type === "content.delta" && event.payload.streamKind === "reasoning_text",
881 );
882 assert.equal(reasoningDelta?.type, "content.delta");
883 if (reasoningDelta?.type === "content.delta") {
884 assert.equal(reasoningDelta.payload.delta, "Let");
885 assert.equal(String(reasoningDelta.turnId), String(turn.turnId));
886 }
887
888 const toolStarted = runtimeEvents.find((event) => event.type === "item.started");
889 assert.equal(toolStarted?.type, "item.started");
890 if (toolStarted?.type === "item.started") {
891 assert.equal(toolStarted.payload.itemType, "dynamic_tool_call");
892 }
893
894 const toolInputUpdated = runtimeEvents.find(
895 (event) =>
896 event.type === "item.updated" &&
897 (event.payload.data as { input?: { pattern?: string; path?: string } } | undefined)?.input
898 ?.pattern === "foo",
899 );
900 assert.equal(toolInputUpdated?.type, "item.updated");
901 if (toolInputUpdated?.type === "item.updated") {
902 assert.deepEqual(toolInputUpdated.payload.data, {
903 toolName: "Grep",
904 input: {
905 pattern: "foo",
906 path: "src",
907 },
908 });
909 }
910
911 const toolResultUpdated = runtimeEvents.find(
912 (event) =>
913 event.type === "item.updated" &&
914 (event.payload.data as { result?: { tool_use_id?: string } } | undefined)?.result
915 ?.tool_use_id === "tool-grep-1",
916 );
917 assert.equal(toolResultUpdated?.type, "item.updated");
918 if (toolResultUpdated?.type === "item.updated") {
919 assert.equal(
920 (
921 toolResultUpdated.payload.data as {
922 result?: { content?: string };
923 }
924 ).result?.content,
925 "src/example.ts:1:foo",
926 );
927 }
928 }).pipe(
929 Effect.provideService(Random.Random, makeDeterministicRandomService()),
930 Effect.provide(harness.layer),
931 );
932 });
933
934 it.effect("classifies Claude Task tool invocations as collaboration agent work", () => {
935 const harness = makeHarness();
936 return Effect.gen(function* () {
937 const adapter = yield* ClaudeAdapter;
938
939 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(
940 Stream.runCollect,
941 Effect.forkChild,
942 );
943
944 const session = yield* adapter.startSession({
945 threadId: THREAD_ID,
946 provider: "claudeAgent",
947 runtimeMode: "full-access",
948 });
949
950 yield* adapter.sendTurn({
951 threadId: session.threadId,
952 input: "delegate this",
953 attachments: [],
954 });
955
956 harness.query.emit({
957 type: "stream_event",
958 session_id: "sdk-session-task",
959 uuid: "stream-task-1",
960 parent_tool_use_id: null,
961 event: {
962 type: "content_block_start",
963 index: 0,
964 content_block: {
965 type: "tool_use",
966 id: "tool-task-1",
967 name: "Task",
968 input: {
969 description: "Review the database layer",
970 prompt: "Audit the SQL changes",
971 subagent_type: "code-reviewer",
972 },
973 },
974 },
975 } as unknown as SDKMessage);
976
977 harness.query.emit({
978 type: "assistant",
979 session_id: "sdk-session-task",
980 uuid: "assistant-task-1",
981 parent_tool_use_id: null,
982 message: {
983 id: "assistant-message-task-1",
984 content: [{ type: "text", text: "Delegated" }],
985 },
986 } as unknown as SDKMessage);
987
988 harness.query.emit({
989 type: "result",
990 subtype: "success",
991 is_error: false,
992 errors: [],
993 session_id: "sdk-session-task",
994 uuid: "result-task-1",
995 } as unknown as SDKMessage);
996
997 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
998 const toolStarted = runtimeEvents.find((event) => event.type === "item.started");
999 assert.equal(toolStarted?.type, "item.started");
1000 if (toolStarted?.type === "item.started") {
1001 assert.equal(toolStarted.payload.itemType, "collab_agent_tool_call");
1002 assert.equal(toolStarted.payload.title, "Subagent task");
1003 }
1004 }).pipe(
1005 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1006 Effect.provide(harness.layer),
1007 );
1008 });
1009
1010 it.effect("treats user-aborted Claude results as interrupted without a runtime error", () => {
1011 const harness = makeHarness();
1012 return Effect.gen(function* () {
1013 const adapter = yield* ClaudeAdapter;
1014
1015 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
1016 Stream.runCollect,
1017 Effect.forkChild,
1018 );
1019
1020 const session = yield* adapter.startSession({
1021 threadId: THREAD_ID,
1022 provider: "claudeAgent",
1023 runtimeMode: "full-access",
1024 });
1025
1026 const turn = yield* adapter.sendTurn({
1027 threadId: session.threadId,
1028 input: "hello",
1029 attachments: [],
1030 });
1031
1032 harness.query.emit({
1033 type: "result",
1034 subtype: "error_during_execution",
1035 is_error: false,
1036 errors: ["Error: Request was aborted."],
1037 stop_reason: "tool_use",
1038 session_id: "sdk-session-abort",
1039 uuid: "result-abort",
1040 } as unknown as SDKMessage);
1041
1042 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1043 assert.deepEqual(
1044 runtimeEvents.map((event) => event.type),
1045 [
1046 "session.started",
1047 "session.configured",
1048 "session.state.changed",
1049 "turn.started",
1050 "thread.started",
1051 "turn.completed",
1052 ],
1053 );
1054
1055 const turnCompleted = runtimeEvents[runtimeEvents.length - 1];
1056 assert.equal(turnCompleted?.type, "turn.completed");
1057 if (turnCompleted?.type === "turn.completed") {
1058 assert.equal(String(turnCompleted.turnId), String(turn.turnId));
1059 assert.equal(turnCompleted.payload.state, "interrupted");
1060 assert.equal(turnCompleted.payload.errorMessage, "Error: Request was aborted.");
1061 assert.equal(turnCompleted.payload.stopReason, "tool_use");
1062 }
1063 }).pipe(
1064 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1065 Effect.provide(harness.layer),
1066 );
1067 });
1068
1069 it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
1070 const harness = makeHarness();
1071 return Effect.gen(function* () {
1072 const adapter = yield* ClaudeAdapter;
1073 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
1074
1075 const runtimeEventsFiber = Effect.runFork(
1076 Stream.runForEach(adapter.streamEvents, (event) =>
1077 Effect.sync(() => {
1078 runtimeEvents.push(event);
1079 }),
1080 ),
1081 );
1082
1083 yield* adapter.startSession({
1084 threadId: THREAD_ID,
1085 provider: "claudeAgent",
1086 runtimeMode: "full-access",
1087 });
1088
1089 const turn = yield* adapter.sendTurn({
1090 threadId: THREAD_ID,
1091 input: "hello",
1092 attachments: [],
1093 });
1094
1095 harness.query.fail(new Error("All fibers interrupted without error"));
1096
1097 yield* Effect.yieldNow;
1098 yield* Effect.yieldNow;
1099 yield* Effect.yieldNow;
1100 runtimeEventsFiber.interruptUnsafe();
1101 assert.deepEqual(
1102 runtimeEvents.map((event) => event.type),
1103 [
1104 "session.started",
1105 "session.configured",
1106 "session.state.changed",
1107 "turn.started",
1108 "turn.completed",
1109 "session.exited",
1110 ],
1111 );
1112
1113 const turnCompleted = runtimeEvents[4];
1114 assert.equal(turnCompleted?.type, "turn.completed");
1115 if (turnCompleted?.type === "turn.completed") {
1116 assert.equal(String(turnCompleted.turnId), String(turn.turnId));
1117 assert.equal(turnCompleted.payload.state, "interrupted");
1118 assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted.");
1119 }
1120
1121 const sessionExited = runtimeEvents[5];
1122 assert.equal(sessionExited?.type, "session.exited");
1123
1124 assert.equal(yield* adapter.hasSession(THREAD_ID), false);
1125 const sessions = yield* adapter.listSessions();
1126 assert.equal(sessions.length, 0);
1127 assert.equal(harness.query.closeCalls, 1);
1128 }).pipe(
1129 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1130 Effect.provide(harness.layer),
1131 );
1132 });
1133
1134 it.effect("forwards Claude task progress summaries for subagent updates", () => {
1135 const harness = makeHarness();
1136 return Effect.gen(function* () {
1137 const adapter = yield* ClaudeAdapter;
1138
1139 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
1140 Stream.runCollect,
1141 Effect.forkChild,
1142 );
1143
1144 yield* adapter.startSession({
1145 threadId: THREAD_ID,
1146 provider: "claudeAgent",
1147 runtimeMode: "full-access",
1148 });
1149
1150 harness.query.emit({
1151 type: "system",
1152 subtype: "task_progress",
1153 task_id: "task-subagent-1",
1154 description: "Running background teammate",
1155 summary: "Code reviewer checked the migration edge cases.",
1156 usage: {
1157 total_tokens: 123,
1158 tool_uses: 4,
1159 duration_ms: 987,
1160 },
1161 session_id: "sdk-session-task-summary",
1162 uuid: "task-progress-1",
1163 } as unknown as SDKMessage);
1164
1165 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1166 const progressEvent = runtimeEvents.find((event) => event.type === "task.progress");
1167 assert.equal(progressEvent?.type, "task.progress");
1168 if (progressEvent?.type === "task.progress") {
1169 assert.equal(
1170 progressEvent.payload.summary,
1171 "Code reviewer checked the migration edge cases.",
1172 );
1173 assert.equal(progressEvent.payload.description, "Running background teammate");
1174 }
1175 }).pipe(
1176 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1177 Effect.provide(harness.layer),
1178 );
1179 });
1180
1181 it.effect(
1182 "emits completion only after turn result when assistant frames arrive before deltas",
1183 () => {
1184 const harness = makeHarness();
1185 return Effect.gen(function* () {
1186 const adapter = yield* ClaudeAdapter;
1187
1188 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(
1189 Stream.runCollect,
1190 Effect.forkChild,
1191 );
1192
1193 const session = yield* adapter.startSession({
1194 threadId: THREAD_ID,
1195 provider: "claudeAgent",
1196 runtimeMode: "full-access",
1197 });
1198
1199 const turn = yield* adapter.sendTurn({
1200 threadId: session.threadId,
1201 input: "hello",
1202 attachments: [],
1203 });
1204
1205 harness.query.emit({
1206 type: "assistant",
1207 session_id: "sdk-session-early-assistant",
1208 uuid: "assistant-early",
1209 parent_tool_use_id: null,
1210 message: {
1211 id: "assistant-message-early",
1212 content: [
1213 { type: "tool_use", id: "tool-early", name: "Read", input: { path: "a.ts" } },
1214 ],
1215 },
1216 } as unknown as SDKMessage);
1217
1218 harness.query.emit({
1219 type: "stream_event",
1220 session_id: "sdk-session-early-assistant",
1221 uuid: "stream-early",
1222 parent_tool_use_id: null,
1223 event: {
1224 type: "content_block_delta",
1225 index: 0,
1226 delta: {
1227 type: "text_delta",
1228 text: "Late text",
1229 },
1230 },
1231 } as unknown as SDKMessage);
1232
1233 harness.query.emit({
1234 type: "result",
1235 subtype: "success",
1236 is_error: false,
1237 errors: [],
1238 session_id: "sdk-session-early-assistant",
1239 uuid: "result-early",
1240 } as unknown as SDKMessage);
1241
1242 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1243 assert.deepEqual(
1244 runtimeEvents.map((event) => event.type),
1245 [
1246 "session.started",
1247 "session.configured",
1248 "session.state.changed",
1249 "turn.started",
1250 "thread.started",
1251 "content.delta",
1252 "item.completed",
1253 "turn.completed",
1254 ],
1255 );
1256
1257 const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta");
1258 const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed");
1259 assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true);
1260
1261 const deltaEvent = runtimeEvents[deltaIndex];
1262 assert.equal(deltaEvent?.type, "content.delta");
1263 if (deltaEvent?.type === "content.delta") {
1264 assert.equal(deltaEvent.payload.delta, "Late text");
1265 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
1266 }
1267 }).pipe(
1268 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1269 Effect.provide(harness.layer),
1270 );
1271 },
1272 );
1273
1274 it.effect("creates a fresh assistant message when Claude reuses a text block index", () => {
1275 const harness = makeHarness();
1276 return Effect.gen(function* () {
1277 const adapter = yield* ClaudeAdapter;
1278
1279 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
1280 Stream.runCollect,
1281 Effect.forkChild,
1282 );
1283
1284 const session = yield* adapter.startSession({
1285 threadId: THREAD_ID,
1286 provider: "claudeAgent",
1287 runtimeMode: "full-access",
1288 });
1289
1290 yield* adapter.sendTurn({
1291 threadId: session.threadId,
1292 input: "hello",
1293 attachments: [],
1294 });
1295
1296 harness.query.emit({
1297 type: "stream_event",
1298 session_id: "sdk-session-reused-text-index",
1299 uuid: "stream-reused-start-1",
1300 parent_tool_use_id: null,
1301 event: {
1302 type: "content_block_start",
1303 index: 0,
1304 content_block: {
1305 type: "text",
1306 text: "",
1307 },
1308 },
1309 } as unknown as SDKMessage);
1310
1311 harness.query.emit({
1312 type: "stream_event",
1313 session_id: "sdk-session-reused-text-index",
1314 uuid: "stream-reused-delta-1",
1315 parent_tool_use_id: null,
1316 event: {
1317 type: "content_block_delta",
1318 index: 0,
1319 delta: {
1320 type: "text_delta",
1321 text: "First",
1322 },
1323 },
1324 } as unknown as SDKMessage);
1325
1326 harness.query.emit({
1327 type: "stream_event",
1328 session_id: "sdk-session-reused-text-index",
1329 uuid: "stream-reused-stop-1",
1330 parent_tool_use_id: null,
1331 event: {
1332 type: "content_block_stop",
1333 index: 0,
1334 },
1335 } as unknown as SDKMessage);
1336
1337 harness.query.emit({
1338 type: "stream_event",
1339 session_id: "sdk-session-reused-text-index",
1340 uuid: "stream-reused-start-2",
1341 parent_tool_use_id: null,
1342 event: {
1343 type: "content_block_start",
1344 index: 0,
1345 content_block: {
1346 type: "text",
1347 text: "",
1348 },
1349 },
1350 } as unknown as SDKMessage);
1351
1352 harness.query.emit({
1353 type: "stream_event",
1354 session_id: "sdk-session-reused-text-index",
1355 uuid: "stream-reused-delta-2",
1356 parent_tool_use_id: null,
1357 event: {
1358 type: "content_block_delta",
1359 index: 0,
1360 delta: {
1361 type: "text_delta",
1362 text: "Second",
1363 },
1364 },
1365 } as unknown as SDKMessage);
1366
1367 harness.query.emit({
1368 type: "stream_event",
1369 session_id: "sdk-session-reused-text-index",
1370 uuid: "stream-reused-stop-2",
1371 parent_tool_use_id: null,
1372 event: {
1373 type: "content_block_stop",
1374 index: 0,
1375 },
1376 } as unknown as SDKMessage);
1377
1378 harness.query.emit({
1379 type: "result",
1380 subtype: "success",
1381 is_error: false,
1382 errors: [],
1383 session_id: "sdk-session-reused-text-index",
1384 uuid: "result-reused-text-index",
1385 } as unknown as SDKMessage);
1386
1387 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1388 assert.deepEqual(
1389 runtimeEvents.map((event) => event.type),
1390 [
1391 "session.started",
1392 "session.configured",
1393 "session.state.changed",
1394 "turn.started",
1395 "thread.started",
1396 "content.delta",
1397 "item.completed",
1398 "content.delta",
1399 "item.completed",
1400 ],
1401 );
1402
1403 const assistantDeltas = runtimeEvents.filter(
1404 (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text",
1405 );
1406 assert.equal(assistantDeltas.length, 2);
1407 if (assistantDeltas.length !== 2) {
1408 return;
1409 }
1410 const [firstAssistantDelta, secondAssistantDelta] = assistantDeltas;
1411 assert.equal(firstAssistantDelta?.type, "content.delta");
1412 assert.equal(secondAssistantDelta?.type, "content.delta");
1413 if (
1414 firstAssistantDelta?.type !== "content.delta" ||
1415 secondAssistantDelta?.type !== "content.delta"
1416 ) {
1417 return;
1418 }
1419 assert.equal(firstAssistantDelta.payload.delta, "First");
1420 assert.equal(secondAssistantDelta.payload.delta, "Second");
1421 assert.notEqual(firstAssistantDelta.itemId, secondAssistantDelta.itemId);
1422
1423 const assistantCompletions = runtimeEvents.filter(
1424 (event) =>
1425 event.type === "item.completed" && event.payload.itemType === "assistant_message",
1426 );
1427 assert.equal(assistantCompletions.length, 2);
1428 assert.equal(String(assistantCompletions[0]?.itemId), String(firstAssistantDelta.itemId));
1429 assert.equal(String(assistantCompletions[1]?.itemId), String(secondAssistantDelta.itemId));
1430 assert.notEqual(
1431 String(assistantCompletions[0]?.itemId),
1432 String(assistantCompletions[1]?.itemId),
1433 );
1434 }).pipe(
1435 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1436 Effect.provide(harness.layer),
1437 );
1438 });
1439
1440 it.effect("falls back to assistant payload text when stream deltas are absent", () => {
1441 const harness = makeHarness();
1442 return Effect.gen(function* () {
1443 const adapter = yield* ClaudeAdapter;
1444
1445 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(
1446 Stream.runCollect,
1447 Effect.forkChild,
1448 );
1449
1450 const session = yield* adapter.startSession({
1451 threadId: THREAD_ID,
1452 provider: "claudeAgent",
1453 runtimeMode: "full-access",
1454 });
1455
1456 const turn = yield* adapter.sendTurn({
1457 threadId: session.threadId,
1458 input: "hello",
1459 attachments: [],
1460 });
1461
1462 harness.query.emit({
1463 type: "assistant",
1464 session_id: "sdk-session-fallback-text",
1465 uuid: "assistant-fallback",
1466 parent_tool_use_id: null,
1467 message: {
1468 id: "assistant-message-fallback",
1469 content: [{ type: "text", text: "Fallback hello" }],
1470 },
1471 } as unknown as SDKMessage);
1472
1473 harness.query.emit({
1474 type: "result",
1475 subtype: "success",
1476 is_error: false,
1477 errors: [],
1478 session_id: "sdk-session-fallback-text",
1479 uuid: "result-fallback",
1480 } as unknown as SDKMessage);
1481
1482 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1483 assert.deepEqual(
1484 runtimeEvents.map((event) => event.type),
1485 [
1486 "session.started",
1487 "session.configured",
1488 "session.state.changed",
1489 "turn.started",
1490 "thread.started",
1491 "content.delta",
1492 "item.completed",
1493 "turn.completed",
1494 ],
1495 );
1496
1497 const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta");
1498 assert.equal(deltaEvent?.type, "content.delta");
1499 if (deltaEvent?.type === "content.delta") {
1500 assert.equal(deltaEvent.payload.delta, "Fallback hello");
1501 assert.equal(String(deltaEvent.turnId), String(turn.turnId));
1502 }
1503 }).pipe(
1504 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1505 Effect.provide(harness.layer),
1506 );
1507 });
1508
1509 it.effect("segments Claude assistant text blocks around tool calls", () => {
1510 const harness = makeHarness();
1511 return Effect.gen(function* () {
1512 const adapter = yield* ClaudeAdapter;
1513
1514 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe(
1515 Stream.runCollect,
1516 Effect.forkChild,
1517 );
1518
1519 const session = yield* adapter.startSession({
1520 threadId: THREAD_ID,
1521 provider: "claudeAgent",
1522 runtimeMode: "full-access",
1523 });
1524
1525 yield* adapter.sendTurn({
1526 threadId: session.threadId,
1527 input: "hello",
1528 attachments: [],
1529 });
1530
1531 harness.query.emit({
1532 type: "stream_event",
1533 session_id: "sdk-session-interleaved",
1534 uuid: "stream-text-1-start",
1535 parent_tool_use_id: null,
1536 event: {
1537 type: "content_block_start",
1538 index: 0,
1539 content_block: {
1540 type: "text",
1541 text: "",
1542 },
1543 },
1544 } as unknown as SDKMessage);
1545
1546 harness.query.emit({
1547 type: "stream_event",
1548 session_id: "sdk-session-interleaved",
1549 uuid: "stream-text-1-delta",
1550 parent_tool_use_id: null,
1551 event: {
1552 type: "content_block_delta",
1553 index: 0,
1554 delta: {
1555 type: "text_delta",
1556 text: "First message.",
1557 },
1558 },
1559 } as unknown as SDKMessage);
1560
1561 harness.query.emit({
1562 type: "stream_event",
1563 session_id: "sdk-session-interleaved",
1564 uuid: "stream-text-1-stop",
1565 parent_tool_use_id: null,
1566 event: {
1567 type: "content_block_stop",
1568 index: 0,
1569 },
1570 } as unknown as SDKMessage);
1571
1572 harness.query.emit({
1573 type: "stream_event",
1574 session_id: "sdk-session-interleaved",
1575 uuid: "stream-tool-start",
1576 parent_tool_use_id: null,
1577 event: {
1578 type: "content_block_start",
1579 index: 1,
1580 content_block: {
1581 type: "tool_use",
1582 id: "tool-interleaved-1",
1583 name: "Grep",
1584 input: {
1585 pattern: "assistant",
1586 path: "src",
1587 },
1588 },
1589 },
1590 } as unknown as SDKMessage);
1591
1592 harness.query.emit({
1593 type: "stream_event",
1594 session_id: "sdk-session-interleaved",
1595 uuid: "stream-tool-stop",
1596 parent_tool_use_id: null,
1597 event: {
1598 type: "content_block_stop",
1599 index: 1,
1600 },
1601 } as unknown as SDKMessage);
1602
1603 harness.query.emit({
1604 type: "user",
1605 session_id: "sdk-session-interleaved",
1606 uuid: "user-tool-result-interleaved",
1607 parent_tool_use_id: null,
1608 message: {
1609 role: "user",
1610 content: [
1611 {
1612 type: "tool_result",
1613 tool_use_id: "tool-interleaved-1",
1614 content: "src/example.ts:1:assistant",
1615 },
1616 ],
1617 },
1618 } as unknown as SDKMessage);
1619
1620 harness.query.emit({
1621 type: "stream_event",
1622 session_id: "sdk-session-interleaved",
1623 uuid: "stream-text-2-start",
1624 parent_tool_use_id: null,
1625 event: {
1626 type: "content_block_start",
1627 index: 2,
1628 content_block: {
1629 type: "text",
1630 text: "",
1631 },
1632 },
1633 } as unknown as SDKMessage);
1634
1635 harness.query.emit({
1636 type: "stream_event",
1637 session_id: "sdk-session-interleaved",
1638 uuid: "stream-text-2-delta",
1639 parent_tool_use_id: null,
1640 event: {
1641 type: "content_block_delta",
1642 index: 2,
1643 delta: {
1644 type: "text_delta",
1645 text: "Second message.",
1646 },
1647 },
1648 } as unknown as SDKMessage);
1649
1650 harness.query.emit({
1651 type: "stream_event",
1652 session_id: "sdk-session-interleaved",
1653 uuid: "stream-text-2-stop",
1654 parent_tool_use_id: null,
1655 event: {
1656 type: "content_block_stop",
1657 index: 2,
1658 },
1659 } as unknown as SDKMessage);
1660
1661 harness.query.emit({
1662 type: "result",
1663 subtype: "success",
1664 is_error: false,
1665 errors: [],
1666 session_id: "sdk-session-interleaved",
1667 uuid: "result-interleaved",
1668 } as unknown as SDKMessage);
1669
1670 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1671 assert.deepEqual(
1672 runtimeEvents.map((event) => event.type),
1673 [
1674 "session.started",
1675 "session.configured",
1676 "session.state.changed",
1677 "turn.started",
1678 "thread.started",
1679 "content.delta",
1680 "item.completed",
1681 "item.started",
1682 "item.updated",
1683 "item.completed",
1684 "content.delta",
1685 "item.completed",
1686 "turn.completed",
1687 ],
1688 );
1689
1690 const assistantTextDeltas = runtimeEvents.filter(
1691 (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text",
1692 );
1693 assert.equal(assistantTextDeltas.length, 2);
1694 if (assistantTextDeltas.length !== 2) {
1695 return;
1696 }
1697 const [firstAssistantDelta, secondAssistantDelta] = assistantTextDeltas;
1698 if (!firstAssistantDelta || !secondAssistantDelta) {
1699 return;
1700 }
1701 assert.notEqual(String(firstAssistantDelta.itemId), String(secondAssistantDelta.itemId));
1702
1703 const firstAssistantCompletedIndex = runtimeEvents.findIndex(
1704 (event) =>
1705 event.type === "item.completed" &&
1706 event.payload.itemType === "assistant_message" &&
1707 String(event.itemId) === String(firstAssistantDelta.itemId),
1708 );
1709 const toolStartedIndex = runtimeEvents.findIndex((event) => event.type === "item.started");
1710 const secondAssistantDeltaIndex = runtimeEvents.findIndex(
1711 (event) =>
1712 event.type === "content.delta" &&
1713 event.payload.streamKind === "assistant_text" &&
1714 String(event.itemId) === String(secondAssistantDelta.itemId),
1715 );
1716
1717 assert.equal(
1718 firstAssistantCompletedIndex >= 0 &&
1719 toolStartedIndex >= 0 &&
1720 secondAssistantDeltaIndex >= 0 &&
1721 firstAssistantCompletedIndex < toolStartedIndex &&
1722 toolStartedIndex < secondAssistantDeltaIndex,
1723 true,
1724 );
1725 }).pipe(
1726 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1727 Effect.provide(harness.layer),
1728 );
1729 });
1730
1731 it.effect("does not fabricate provider thread ids before first SDK session_id", () => {
1732 const harness = makeHarness();
1733 return Effect.gen(function* () {
1734 const adapter = yield* ClaudeAdapter;
1735
1736 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
1737 Stream.runCollect,
1738 Effect.forkChild,
1739 );
1740
1741 const session = yield* adapter.startSession({
1742 threadId: THREAD_ID,
1743 provider: "claudeAgent",
1744 runtimeMode: "full-access",
1745 });
1746 assert.equal(session.threadId, THREAD_ID);
1747
1748 const turn = yield* adapter.sendTurn({
1749 threadId: session.threadId,
1750 input: "hello",
1751 attachments: [],
1752 });
1753 assert.equal(turn.threadId, THREAD_ID);
1754
1755 harness.query.emit({
1756 type: "stream_event",
1757 session_id: "sdk-thread-real",
1758 uuid: "stream-thread-real",
1759 parent_tool_use_id: null,
1760 event: {
1761 type: "message_start",
1762 message: {
1763 id: "msg-thread-real",
1764 },
1765 },
1766 } as unknown as SDKMessage);
1767
1768 harness.query.emit({
1769 type: "result",
1770 subtype: "success",
1771 is_error: false,
1772 errors: [],
1773 session_id: "sdk-thread-real",
1774 uuid: "result-thread-real",
1775 } as unknown as SDKMessage);
1776
1777 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1778 assert.deepEqual(
1779 runtimeEvents.map((event) => event.type),
1780 [
1781 "session.started",
1782 "session.configured",
1783 "session.state.changed",
1784 "turn.started",
1785 "thread.started",
1786 ],
1787 );
1788
1789 const sessionStarted = runtimeEvents[0];
1790 assert.equal(sessionStarted?.type, "session.started");
1791 if (sessionStarted?.type === "session.started") {
1792 assert.equal(sessionStarted.threadId, THREAD_ID);
1793 }
1794
1795 const threadStarted = runtimeEvents[4];
1796 assert.equal(threadStarted?.type, "thread.started");
1797 if (threadStarted?.type === "thread.started") {
1798 assert.equal(threadStarted.threadId, THREAD_ID);
1799 assert.deepEqual(threadStarted.payload, {
1800 providerThreadId: "sdk-thread-real",
1801 });
1802 }
1803 }).pipe(
1804 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1805 Effect.provide(harness.layer),
1806 );
1807 });
1808
1809 it.effect("bridges approval request/response lifecycle through canUseTool", () => {
1810 const harness = makeHarness();
1811 return Effect.gen(function* () {
1812 const adapter = yield* ClaudeAdapter;
1813
1814 const session = yield* adapter.startSession({
1815 threadId: THREAD_ID,
1816 provider: "claudeAgent",
1817 runtimeMode: "approval-required",
1818 });
1819
1820 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
1821
1822 yield* adapter.sendTurn({
1823 threadId: session.threadId,
1824 input: "approve this",
1825 attachments: [],
1826 });
1827 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
1828
1829 harness.query.emit({
1830 type: "stream_event",
1831 session_id: "sdk-session-approval-1",
1832 uuid: "stream-approval-thread",
1833 parent_tool_use_id: null,
1834 event: {
1835 type: "message_start",
1836 message: {
1837 id: "msg-approval-thread",
1838 },
1839 },
1840 } as unknown as SDKMessage);
1841
1842 const threadStarted = yield* Stream.runHead(adapter.streamEvents);
1843 assert.equal(threadStarted._tag, "Some");
1844 if (threadStarted._tag !== "Some" || threadStarted.value.type !== "thread.started") {
1845 return;
1846 }
1847
1848 const createInput = harness.getLastCreateQueryInput();
1849 const canUseTool = createInput?.options.canUseTool;
1850 assert.equal(typeof canUseTool, "function");
1851 if (!canUseTool) {
1852 return;
1853 }
1854
1855 const permissionPromise = canUseTool(
1856 "Bash",
1857 { command: "pwd" },
1858 {
1859 signal: new AbortController().signal,
1860 suggestions: [
1861 {
1862 type: "setMode",
1863 mode: "default",
1864 destination: "session",
1865 },
1866 ],
1867 toolUseID: "tool-use-1",
1868 },
1869 );
1870
1871 const requested = yield* Stream.runHead(adapter.streamEvents);
1872 assert.equal(requested._tag, "Some");
1873 if (requested._tag !== "Some") {
1874 return;
1875 }
1876 assert.equal(requested.value.type, "request.opened");
1877 if (requested.value.type !== "request.opened") {
1878 return;
1879 }
1880 assert.deepEqual(requested.value.providerRefs, {
1881 providerItemId: ProviderItemId.makeUnsafe("tool-use-1"),
1882 });
1883 const runtimeRequestId = requested.value.requestId;
1884 assert.equal(typeof runtimeRequestId, "string");
1885 if (runtimeRequestId === undefined) {
1886 return;
1887 }
1888
1889 yield* adapter.respondToRequest(
1890 session.threadId,
1891 ApprovalRequestId.makeUnsafe(runtimeRequestId),
1892 "accept",
1893 );
1894
1895 const resolved = yield* Stream.runHead(adapter.streamEvents);
1896 assert.equal(resolved._tag, "Some");
1897 if (resolved._tag !== "Some") {
1898 return;
1899 }
1900 assert.equal(resolved.value.type, "request.resolved");
1901 if (resolved.value.type !== "request.resolved") {
1902 return;
1903 }
1904 assert.equal(resolved.value.requestId, requested.value.requestId);
1905 assert.equal(resolved.value.payload.decision, "accept");
1906 assert.deepEqual(resolved.value.providerRefs, {
1907 providerItemId: ProviderItemId.makeUnsafe("tool-use-1"),
1908 });
1909
1910 const permissionResult = yield* Effect.promise(() => permissionPromise);
1911 assert.equal((permissionResult as PermissionResult).behavior, "allow");
1912 }).pipe(
1913 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1914 Effect.provide(harness.layer),
1915 );
1916 });
1917
1918 it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => {
1919 const harness = makeHarness();
1920 return Effect.gen(function* () {
1921 const adapter = yield* ClaudeAdapter;
1922
1923 const session = yield* adapter.startSession({
1924 threadId: THREAD_ID,
1925 provider: "claudeAgent",
1926 runtimeMode: "approval-required",
1927 });
1928
1929 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
1930
1931 const createInput = harness.getLastCreateQueryInput();
1932 const canUseTool = createInput?.options.canUseTool;
1933 assert.equal(typeof canUseTool, "function");
1934 if (!canUseTool) {
1935 return;
1936 }
1937
1938 const agentPermissionPromise = canUseTool(
1939 "Agent",
1940 {},
1941 {
1942 signal: new AbortController().signal,
1943 toolUseID: "tool-agent-1",
1944 },
1945 );
1946
1947 const agentRequested = yield* Stream.runHead(adapter.streamEvents);
1948 assert.equal(agentRequested._tag, "Some");
1949 if (agentRequested._tag !== "Some" || agentRequested.value.type !== "request.opened") {
1950 return;
1951 }
1952 assert.equal(agentRequested.value.payload.requestType, "dynamic_tool_call");
1953
1954 yield* adapter.respondToRequest(
1955 session.threadId,
1956 ApprovalRequestId.makeUnsafe(String(agentRequested.value.requestId)),
1957 "accept",
1958 );
1959 yield* Stream.runHead(adapter.streamEvents);
1960 yield* Effect.promise(() => agentPermissionPromise);
1961
1962 const grepPermissionPromise = canUseTool(
1963 "Grep",
1964 { pattern: "foo", path: "src" },
1965 {
1966 signal: new AbortController().signal,
1967 toolUseID: "tool-grep-approval-1",
1968 },
1969 );
1970
1971 const grepRequested = yield* Stream.runHead(adapter.streamEvents);
1972 assert.equal(grepRequested._tag, "Some");
1973 if (grepRequested._tag !== "Some" || grepRequested.value.type !== "request.opened") {
1974 return;
1975 }
1976 assert.equal(grepRequested.value.payload.requestType, "file_read_approval");
1977
1978 yield* adapter.respondToRequest(
1979 session.threadId,
1980 ApprovalRequestId.makeUnsafe(String(grepRequested.value.requestId)),
1981 "accept",
1982 );
1983 yield* Stream.runHead(adapter.streamEvents);
1984 yield* Effect.promise(() => grepPermissionPromise);
1985 }).pipe(
1986 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1987 Effect.provide(harness.layer),
1988 );
1989 });
1990
1991 it.effect("passes Claude resume ids without pinning a stale assistant checkpoint", () => {
1992 const harness = makeHarness();
1993 return Effect.gen(function* () {
1994 const adapter = yield* ClaudeAdapter;
1995
1996 const session = yield* adapter.startSession({
1997 threadId: RESUME_THREAD_ID,
1998 provider: "claudeAgent",
1999 resumeCursor: {
2000 threadId: "resume-thread-1",
2001 resume: "550e8400-e29b-41d4-a716-446655440000",
2002 resumeSessionAt: "assistant-99",
2003 turnCount: 3,
2004 },
2005 runtimeMode: "full-access",
2006 });
2007
2008 assert.equal(session.threadId, RESUME_THREAD_ID);
2009 assert.deepEqual(session.resumeCursor, {
2010 threadId: RESUME_THREAD_ID,
2011 resume: "550e8400-e29b-41d4-a716-446655440000",
2012 resumeSessionAt: "assistant-99",
2013 turnCount: 3,
2014 });
2015
2016 const createInput = harness.getLastCreateQueryInput();
2017 assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000");
2018 assert.equal(createInput?.options.sessionId, undefined);
2019 assert.equal(createInput?.options.resumeSessionAt, undefined);
2020 }).pipe(
2021 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2022 Effect.provide(harness.layer),
2023 );
2024 });
2025
2026 it.effect("uses an app-generated Claude session id for fresh sessions", () => {
2027 const harness = makeHarness();
2028 return Effect.gen(function* () {
2029 const adapter = yield* ClaudeAdapter;
2030
2031 const session = yield* adapter.startSession({
2032 threadId: THREAD_ID,
2033 provider: "claudeAgent",
2034 runtimeMode: "full-access",
2035 });
2036
2037 const createInput = harness.getLastCreateQueryInput();
2038 const sessionResumeCursor = session.resumeCursor as {
2039 threadId?: string;
2040 resume?: string;
2041 turnCount?: number;
2042 };
2043 assert.equal(sessionResumeCursor.threadId, THREAD_ID);
2044 assert.equal(typeof sessionResumeCursor.resume, "string");
2045 assert.equal(sessionResumeCursor.turnCount, 0);
2046 assert.match(
2047 sessionResumeCursor.resume ?? "",
2048 /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
2049 );
2050 assert.equal(createInput?.options.resume, undefined);
2051 assert.equal(createInput?.options.sessionId, sessionResumeCursor.resume);
2052 }).pipe(
2053 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2054 Effect.provide(harness.layer),
2055 );
2056 });
2057
2058 it.effect(
2059 "supports rollbackThread by trimming in-memory turns and preserving earlier turns",
2060 () => {
2061 const harness = makeHarness();
2062 return Effect.gen(function* () {
2063 const adapter = yield* ClaudeAdapter;
2064
2065 const session = yield* adapter.startSession({
2066 threadId: THREAD_ID,
2067 provider: "claudeAgent",
2068 runtimeMode: "full-access",
2069 });
2070
2071 const firstTurn = yield* adapter.sendTurn({
2072 threadId: session.threadId,
2073 input: "first",
2074 attachments: [],
2075 });
2076
2077 const firstCompletedFiber = yield* Stream.filter(
2078 adapter.streamEvents,
2079 (event) => event.type === "turn.completed",
2080 ).pipe(Stream.runHead, Effect.forkChild);
2081
2082 harness.query.emit({
2083 type: "result",
2084 subtype: "success",
2085 is_error: false,
2086 errors: [],
2087 session_id: "sdk-session-rollback",
2088 uuid: "result-first",
2089 } as unknown as SDKMessage);
2090
2091 const firstCompleted = yield* Fiber.join(firstCompletedFiber);
2092 assert.equal(firstCompleted._tag, "Some");
2093 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {
2094 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));
2095 }
2096
2097 const secondTurn = yield* adapter.sendTurn({
2098 threadId: session.threadId,
2099 input: "second",
2100 attachments: [],
2101 });
2102
2103 const secondCompletedFiber = yield* Stream.filter(
2104 adapter.streamEvents,
2105 (event) => event.type === "turn.completed",
2106 ).pipe(Stream.runHead, Effect.forkChild);
2107
2108 harness.query.emit({
2109 type: "result",
2110 subtype: "success",
2111 is_error: false,
2112 errors: [],
2113 session_id: "sdk-session-rollback",
2114 uuid: "result-second",
2115 } as unknown as SDKMessage);
2116
2117 const secondCompleted = yield* Fiber.join(secondCompletedFiber);
2118 assert.equal(secondCompleted._tag, "Some");
2119 if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") {
2120 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
2121 }
2122
2123 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
2124 assert.equal(threadBeforeRollback.turns.length, 2);
2125
2126 const rolledBack = yield* adapter.rollbackThread(session.threadId, 1);
2127 assert.equal(rolledBack.turns.length, 1);
2128 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId);
2129
2130 const threadAfterRollback = yield* adapter.readThread(session.threadId);
2131 assert.equal(threadAfterRollback.turns.length, 1);
2132 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId);
2133 }).pipe(
2134 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2135 Effect.provide(harness.layer),
2136 );
2137 },
2138 );
2139
2140 it.effect("updates model on sendTurn when model override is provided", () => {
2141 const harness = makeHarness();
2142 return Effect.gen(function* () {
2143 const adapter = yield* ClaudeAdapter;
2144
2145 const session = yield* adapter.startSession({
2146 threadId: THREAD_ID,
2147 provider: "claudeAgent",
2148 runtimeMode: "full-access",
2149 });
2150 yield* adapter.sendTurn({
2151 threadId: session.threadId,
2152 input: "hello",
2153 model: "claude-opus-4-6",
2154 attachments: [],
2155 });
2156
2157 assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6"]);
2158 }).pipe(
2159 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2160 Effect.provide(harness.layer),
2161 );
2162 });
2163
2164 it.effect("sets plan permission mode on sendTurn when interactionMode is plan", () => {
2165 const harness = makeHarness();
2166 return Effect.gen(function* () {
2167 const adapter = yield* ClaudeAdapter;
2168
2169 const session = yield* adapter.startSession({
2170 threadId: THREAD_ID,
2171 provider: "claudeAgent",
2172 runtimeMode: "full-access",
2173 });
2174 yield* adapter.sendTurn({
2175 threadId: session.threadId,
2176 input: "plan this for me",
2177 interactionMode: "plan",
2178 attachments: [],
2179 });
2180
2181 assert.deepEqual(harness.query.setPermissionModeCalls, ["plan"]);
2182 }).pipe(
2183 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2184 Effect.provide(harness.layer),
2185 );
2186 });
2187
2188 it.effect("restores base permission mode on sendTurn when interactionMode is default", () => {
2189 const harness = makeHarness();
2190 return Effect.gen(function* () {
2191 const adapter = yield* ClaudeAdapter;
2192
2193 const session = yield* adapter.startSession({
2194 threadId: THREAD_ID,
2195 provider: "claudeAgent",
2196 runtimeMode: "full-access",
2197 });
2198
2199 // First turn in plan mode
2200 yield* adapter.sendTurn({
2201 threadId: session.threadId,
2202 input: "plan this",
2203 interactionMode: "plan",
2204 attachments: [],
2205 });
2206
2207 // Complete the turn so we can send another
2208 const turnCompletedFiber = yield* Stream.filter(
2209 adapter.streamEvents,
2210 (event) => event.type === "turn.completed",
2211 ).pipe(Stream.runHead, Effect.forkChild);
2212
2213 harness.query.emit({
2214 type: "result",
2215 subtype: "success",
2216 is_error: false,
2217 errors: [],
2218 session_id: "sdk-session-plan-restore",
2219 uuid: "result-plan",
2220 } as unknown as SDKMessage);
2221
2222 yield* Fiber.join(turnCompletedFiber);
2223
2224 // Second turn back to default
2225 yield* adapter.sendTurn({
2226 threadId: session.threadId,
2227 input: "now do it",
2228 interactionMode: "default",
2229 attachments: [],
2230 });
2231
2232 // First call sets "plan", second call restores "bypassPermissions" (the base for full-access)
2233 assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]);
2234 }).pipe(
2235 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2236 Effect.provide(harness.layer),
2237 );
2238 });
2239
2240 it.effect("does not call setPermissionMode when interactionMode is absent", () => {
2241 const harness = makeHarness();
2242 return Effect.gen(function* () {
2243 const adapter = yield* ClaudeAdapter;
2244
2245 const session = yield* adapter.startSession({
2246 threadId: THREAD_ID,
2247 provider: "claudeAgent",
2248 runtimeMode: "full-access",
2249 });
2250 yield* adapter.sendTurn({
2251 threadId: session.threadId,
2252 input: "hello",
2253 attachments: [],
2254 });
2255
2256 assert.deepEqual(harness.query.setPermissionModeCalls, []);
2257 }).pipe(
2258 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2259 Effect.provide(harness.layer),
2260 );
2261 });
2262
2263 it.effect("captures ExitPlanMode as a proposed plan and denies auto-exit", () => {
2264 const harness = makeHarness();
2265 return Effect.gen(function* () {
2266 const adapter = yield* ClaudeAdapter;
2267
2268 const session = yield* adapter.startSession({
2269 threadId: THREAD_ID,
2270 provider: "claudeAgent",
2271 runtimeMode: "full-access",
2272 });
2273
2274 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
2275
2276 yield* adapter.sendTurn({
2277 threadId: session.threadId,
2278 input: "plan this",
2279 interactionMode: "plan",
2280 attachments: [],
2281 });
2282 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
2283
2284 const createInput = harness.getLastCreateQueryInput();
2285 const canUseTool = createInput?.options.canUseTool;
2286 assert.equal(typeof canUseTool, "function");
2287 if (!canUseTool) {
2288 return;
2289 }
2290
2291 const permissionPromise = canUseTool(
2292 "ExitPlanMode",
2293 {
2294 plan: "# Ship it\n\n- one\n- two",
2295 allowedPrompts: [{ tool: "Bash", prompt: "run tests" }],
2296 },
2297 {
2298 signal: new AbortController().signal,
2299 toolUseID: "tool-exit-1",
2300 },
2301 );
2302
2303 const proposedEvent = yield* Stream.runHead(adapter.streamEvents);
2304 assert.equal(proposedEvent._tag, "Some");
2305 if (proposedEvent._tag !== "Some") {
2306 return;
2307 }
2308 assert.equal(proposedEvent.value.type, "turn.proposed.completed");
2309 if (proposedEvent.value.type !== "turn.proposed.completed") {
2310 return;
2311 }
2312 assert.equal(proposedEvent.value.payload.planMarkdown, "# Ship it\n\n- one\n- two");
2313 assert.deepEqual(proposedEvent.value.providerRefs, {
2314 providerItemId: ProviderItemId.makeUnsafe("tool-exit-1"),
2315 });
2316
2317 const permissionResult = yield* Effect.promise(() => permissionPromise);
2318 assert.equal((permissionResult as PermissionResult).behavior, "deny");
2319 const deniedResult = permissionResult as PermissionResult & {
2320 message?: string;
2321 };
2322 assert.equal(deniedResult.message?.includes("captured your proposed plan"), true);
2323 }).pipe(
2324 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2325 Effect.provide(harness.layer),
2326 );
2327 });
2328
2329 it.effect("extracts proposed plans from assistant ExitPlanMode snapshots", () => {
2330 const harness = makeHarness();
2331 return Effect.gen(function* () {
2332 const adapter = yield* ClaudeAdapter;
2333
2334 const session = yield* adapter.startSession({
2335 threadId: THREAD_ID,
2336 provider: "claudeAgent",
2337 runtimeMode: "full-access",
2338 });
2339
2340 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
2341
2342 yield* adapter.sendTurn({
2343 threadId: session.threadId,
2344 input: "plan this",
2345 interactionMode: "plan",
2346 attachments: [],
2347 });
2348 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
2349
2350 const proposedEventFiber = yield* Stream.filter(
2351 adapter.streamEvents,
2352 (event) => event.type === "turn.proposed.completed",
2353 ).pipe(Stream.runHead, Effect.forkChild);
2354
2355 harness.query.emit({
2356 type: "assistant",
2357 session_id: "sdk-session-exit-plan",
2358 uuid: "assistant-exit-plan",
2359 parent_tool_use_id: null,
2360 message: {
2361 model: "claude-opus-4-6",
2362 id: "msg-exit-plan",
2363 type: "message",
2364 role: "assistant",
2365 content: [
2366 {
2367 type: "tool_use",
2368 id: "tool-exit-2",
2369 name: "ExitPlanMode",
2370 input: {
2371 plan: "# Final plan\n\n- capture it",
2372 },
2373 },
2374 ],
2375 stop_reason: null,
2376 stop_sequence: null,
2377 usage: {},
2378 },
2379 } as unknown as SDKMessage);
2380
2381 const proposedEvent = yield* Fiber.join(proposedEventFiber);
2382 assert.equal(proposedEvent._tag, "Some");
2383 if (proposedEvent._tag !== "Some") {
2384 return;
2385 }
2386 assert.equal(proposedEvent.value.type, "turn.proposed.completed");
2387 if (proposedEvent.value.type !== "turn.proposed.completed") {
2388 return;
2389 }
2390 assert.equal(proposedEvent.value.payload.planMarkdown, "# Final plan\n\n- capture it");
2391 assert.deepEqual(proposedEvent.value.providerRefs, {
2392 providerItemId: ProviderItemId.makeUnsafe("tool-exit-2"),
2393 });
2394 }).pipe(
2395 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2396 Effect.provide(harness.layer),
2397 );
2398 });
2399
2400 it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {
2401 const harness = makeHarness();
2402 return Effect.gen(function* () {
2403 const adapter = yield* ClaudeAdapter;
2404
2405 // Start session in approval-required mode so canUseTool fires.
2406 const session = yield* adapter.startSession({
2407 threadId: THREAD_ID,
2408 provider: "claudeAgent",
2409 runtimeMode: "approval-required",
2410 });
2411
2412 // Drain the session startup events (started, configured, state.changed).
2413 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
2414
2415 yield* adapter.sendTurn({
2416 threadId: session.threadId,
2417 input: "question turn",
2418 attachments: [],
2419 });
2420 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
2421
2422 harness.query.emit({
2423 type: "stream_event",
2424 session_id: "sdk-session-user-input-1",
2425 uuid: "stream-user-input-thread",
2426 parent_tool_use_id: null,
2427 event: {
2428 type: "message_start",
2429 message: {
2430 id: "msg-user-input-thread",
2431 },
2432 },
2433 } as unknown as SDKMessage);
2434
2435 const threadStarted = yield* Stream.runHead(adapter.streamEvents);
2436 assert.equal(threadStarted._tag, "Some");
2437 if (threadStarted._tag !== "Some" || threadStarted.value.type !== "thread.started") {
2438 return;
2439 }
2440
2441 const createInput = harness.getLastCreateQueryInput();
2442 const canUseTool = createInput?.options.canUseTool;
2443 assert.equal(typeof canUseTool, "function");
2444 if (!canUseTool) {
2445 return;
2446 }
2447
2448 // Simulate Claude calling AskUserQuestion with structured questions.
2449 const askInput = {
2450 questions: [
2451 {
2452 question: "Which framework?",
2453 header: "Framework",
2454 options: [
2455 { label: "React", description: "React.js" },
2456 { label: "Vue", description: "Vue.js" },
2457 ],
2458 multiSelect: false,
2459 },
2460 ],
2461 };
2462
2463 const permissionPromise = canUseTool("AskUserQuestion", askInput, {
2464 signal: new AbortController().signal,
2465 toolUseID: "tool-ask-1",
2466 });
2467
2468 // The adapter should emit a user-input.requested event.
2469 const requestedEvent = yield* Stream.runHead(adapter.streamEvents);
2470 assert.equal(requestedEvent._tag, "Some");
2471 if (requestedEvent._tag !== "Some") {
2472 return;
2473 }
2474 assert.equal(requestedEvent.value.type, "user-input.requested");
2475 if (requestedEvent.value.type !== "user-input.requested") {
2476 return;
2477 }
2478 const requestId = requestedEvent.value.requestId;
2479 assert.equal(typeof requestId, "string");
2480 assert.equal(requestedEvent.value.payload.questions.length, 1);
2481 assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?");
2482 assert.deepEqual(requestedEvent.value.providerRefs, {
2483 providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"),
2484 });
2485
2486 // Respond with the user's answers.
2487 yield* adapter.respondToUserInput(
2488 session.threadId,
2489 ApprovalRequestId.makeUnsafe(requestId!),
2490 { "Which framework?": "React" },
2491 );
2492
2493 // The adapter should emit a user-input.resolved event.
2494 const resolvedEvent = yield* Stream.runHead(adapter.streamEvents);
2495 assert.equal(resolvedEvent._tag, "Some");
2496 if (resolvedEvent._tag !== "Some") {
2497 return;
2498 }
2499 assert.equal(resolvedEvent.value.type, "user-input.resolved");
2500 if (resolvedEvent.value.type !== "user-input.resolved") {
2501 return;
2502 }
2503 assert.deepEqual(resolvedEvent.value.payload.answers, {
2504 "Which framework?": "React",
2505 });
2506 assert.deepEqual(resolvedEvent.value.providerRefs, {
2507 providerItemId: ProviderItemId.makeUnsafe("tool-ask-1"),
2508 });
2509
2510 // The canUseTool promise should resolve with the answers in SDK format.
2511 const permissionResult = yield* Effect.promise(() => permissionPromise);
2512 assert.equal((permissionResult as PermissionResult).behavior, "allow");
2513 const updatedInput = (permissionResult as { updatedInput: Record<string, unknown> })
2514 .updatedInput;
2515 assert.deepEqual(updatedInput.answers, { "Which framework?": "React" });
2516 // Original questions should be passed through.
2517 assert.deepEqual(updatedInput.questions, askInput.questions);
2518 }).pipe(
2519 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2520 Effect.provide(harness.layer),
2521 );
2522 });
2523
2524 it.effect("routes AskUserQuestion through user-input flow even in full-access mode", () => {
2525 const harness = makeHarness();
2526 return Effect.gen(function* () {
2527 const adapter = yield* ClaudeAdapter;
2528
2529 // In full-access mode, regular tools are auto-approved.
2530 // AskUserQuestion should still go through the user-input flow.
2531 const session = yield* adapter.startSession({
2532 threadId: THREAD_ID,
2533 provider: "claudeAgent",
2534 runtimeMode: "full-access",
2535 });
2536
2537 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
2538
2539 const createInput = harness.getLastCreateQueryInput();
2540 const canUseTool = createInput?.options.canUseTool;
2541 assert.equal(typeof canUseTool, "function");
2542 if (!canUseTool) {
2543 return;
2544 }
2545
2546 const askInput = {
2547 questions: [
2548 {
2549 question: "Deploy to which env?",
2550 header: "Env",
2551 options: [
2552 { label: "Staging", description: "Staging environment" },
2553 { label: "Production", description: "Production environment" },
2554 ],
2555 multiSelect: false,
2556 },
2557 ],
2558 };
2559
2560 const permissionPromise = canUseTool("AskUserQuestion", askInput, {
2561 signal: new AbortController().signal,
2562 toolUseID: "tool-ask-2",
2563 });
2564
2565 // Should still get user-input.requested even in full-access mode.
2566 const requestedEvent = yield* Stream.runHead(adapter.streamEvents);
2567 assert.equal(requestedEvent._tag, "Some");
2568 if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") {
2569 assert.fail("Expected user-input.requested event");
2570 return;
2571 }
2572 const requestId = requestedEvent.value.requestId;
2573
2574 yield* adapter.respondToUserInput(
2575 session.threadId,
2576 ApprovalRequestId.makeUnsafe(requestId!),
2577 { "Deploy to which env?": "Staging" },
2578 );
2579
2580 // Drain the resolved event.
2581 yield* Stream.runHead(adapter.streamEvents);
2582
2583 const permissionResult = yield* Effect.promise(() => permissionPromise);
2584 assert.equal((permissionResult as PermissionResult).behavior, "allow");
2585 const updatedInput = (permissionResult as { updatedInput: Record<string, unknown> })
2586 .updatedInput;
2587 assert.deepEqual(updatedInput.answers, { "Deploy to which env?": "Staging" });
2588 }).pipe(
2589 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2590 Effect.provide(harness.layer),
2591 );
2592 });
2593
2594 it.effect("denies AskUserQuestion when the waiting turn is aborted", () => {
2595 const harness = makeHarness();
2596 return Effect.gen(function* () {
2597 const adapter = yield* ClaudeAdapter;
2598
2599 const session = yield* adapter.startSession({
2600 threadId: THREAD_ID,
2601 provider: "claudeAgent",
2602 runtimeMode: "approval-required",
2603 });
2604
2605 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
2606
2607 const createInput = harness.getLastCreateQueryInput();
2608 const canUseTool = createInput?.options.canUseTool;
2609 assert.equal(typeof canUseTool, "function");
2610 if (!canUseTool) {
2611 return;
2612 }
2613
2614 const controller = new AbortController();
2615 const permissionPromise = canUseTool(
2616 "AskUserQuestion",
2617 {
2618 questions: [
2619 {
2620 question: "Continue?",
2621 header: "Continue",
2622 options: [{ label: "Yes", description: "Proceed" }],
2623 multiSelect: false,
2624 },
2625 ],
2626 },
2627 {
2628 signal: controller.signal,
2629 toolUseID: "tool-ask-abort",
2630 },
2631 );
2632
2633 const requestedEvent = yield* Stream.runHead(adapter.streamEvents);
2634 assert.equal(requestedEvent._tag, "Some");
2635 if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") {
2636 assert.fail("Expected user-input.requested event");
2637 return;
2638 }
2639 assert.equal(requestedEvent.value.threadId, session.threadId);
2640
2641 controller.abort();
2642
2643 const resolvedEvent = yield* Stream.runHead(adapter.streamEvents);
2644 assert.equal(resolvedEvent._tag, "Some");
2645 if (resolvedEvent._tag !== "Some" || resolvedEvent.value.type !== "user-input.resolved") {
2646 assert.fail("Expected user-input.resolved event");
2647 return;
2648 }
2649 assert.deepEqual(resolvedEvent.value.payload.answers, {});
2650
2651 const permissionResult = yield* Effect.promise(() => permissionPromise);
2652 assert.deepEqual(permissionResult, {
2653 behavior: "deny",
2654 message: "User cancelled tool execution.",
2655 } satisfies PermissionResult);
2656 }).pipe(
2657 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2658 Effect.provide(harness.layer),
2659 );
2660 });
2661
2662 it.effect("writes provider-native observability records when enabled", () => {
2663 const nativeEvents: Array<{
2664 event?: {
2665 provider?: string;
2666 method?: string;
2667 threadId?: string;
2668 turnId?: string;
2669 };
2670 }> = [];
2671 const nativeThreadIds: Array<string | null> = [];
2672 const harness = makeHarness({
2673 nativeEventLogger: {
2674 filePath: "memory://claude-native-events",
2675 write: (event, threadId) => {
2676 nativeEvents.push(event as (typeof nativeEvents)[number]);
2677 nativeThreadIds.push(threadId ?? null);
2678 return Effect.void;
2679 },
2680 close: () => Effect.void,
2681 },
2682 });
2683 return Effect.gen(function* () {
2684 const adapter = yield* ClaudeAdapter;
2685
2686 const session = yield* adapter.startSession({
2687 threadId: THREAD_ID,
2688 provider: "claudeAgent",
2689 runtimeMode: "full-access",
2690 });
2691 const turn = yield* adapter.sendTurn({
2692 threadId: session.threadId,
2693 input: "hello",
2694 attachments: [],
2695 });
2696
2697 const turnCompletedFiber = yield* Stream.filter(
2698 adapter.streamEvents,
2699 (event) => event.type === "turn.completed",
2700 ).pipe(Stream.runHead, Effect.forkChild);
2701
2702 harness.query.emit({
2703 type: "stream_event",
2704 session_id: "sdk-session-native-log",
2705 uuid: "stream-native-log",
2706 parent_tool_use_id: null,
2707 event: {
2708 type: "content_block_delta",
2709 index: 0,
2710 delta: {
2711 type: "text_delta",
2712 text: "hi",
2713 },
2714 },
2715 } as unknown as SDKMessage);
2716
2717 harness.query.emit({
2718 type: "result",
2719 subtype: "success",
2720 is_error: false,
2721 errors: [],
2722 session_id: "sdk-session-native-log",
2723 uuid: "result-native-log",
2724 } as unknown as SDKMessage);
2725
2726 const turnCompleted = yield* Fiber.join(turnCompletedFiber);
2727 assert.equal(turnCompleted._tag, "Some");
2728
2729 assert.equal(nativeEvents.length > 0, true);
2730 assert.equal(
2731 nativeEvents.some((record) => record.event?.provider === "claudeAgent"),
2732 true,
2733 );
2734 assert.equal(
2735 nativeEvents.some(
2736 (record) =>
2737 String(
2738 (record.event as { readonly providerThreadId?: string } | undefined)
2739 ?.providerThreadId,
2740 ) === "sdk-session-native-log",
2741 ),
2742 true,
2743 );
2744 assert.equal(
2745 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),
2746 true,
2747 );
2748 assert.equal(
2749 nativeEvents.some(
2750 (record) => record.event?.method === "claude/stream_event/content_block_delta/text_delta",
2751 ),
2752 true,
2753 );
2754 assert.equal(
2755 nativeThreadIds.every((threadId) => threadId === String(THREAD_ID)),
2756 true,
2757 );
2758 }).pipe(
2759 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2760 Effect.provide(harness.layer),
2761 );
2762 });
2763});
apps/server/src/provider/Layers/ClaudeAdapter.ts created+2912
......@@ -0,0 +1,2912 @@
1/**
2 * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter.
3 *
4 * Wraps `@anthropic-ai/claude-agent-sdk` query sessions behind the generic
5 * provider adapter contract and emits canonical runtime events.
6 *
7 * @module ClaudeAdapterLive
8 */
9import {
10 type CanUseTool,
11 query,
12 type Options as ClaudeQueryOptions,
13 type PermissionMode,
14 type PermissionResult,
15 type PermissionUpdate,
16 type SDKMessage,
17 type SDKResultMessage,
18 type SDKUserMessage,
19} from "@anthropic-ai/claude-agent-sdk";
20import {
21 ApprovalRequestId,
22 type CanonicalItemType,
23 type CanonicalRequestType,
24 EventId,
25 type ProviderApprovalDecision,
26 ProviderItemId,
27 type ProviderRuntimeEvent,
28 type ProviderRuntimeTurnStatus,
29 type ProviderSendTurnInput,
30 type ProviderSession,
31 type ProviderUserInputAnswers,
32 type RuntimeContentStreamKind,
33 RuntimeItemId,
34 RuntimeRequestId,
35 RuntimeTaskId,
36 ThreadId,
37 TurnId,
38 type UserInputQuestion,
39} from "@t3tools/contracts";
40import {
41 applyClaudePromptEffortPrefix,
42 getEffectiveClaudeCodeEffort,
43 getReasoningEffortOptions,
44 resolveReasoningEffortForProvider,
45 supportsClaudeFastMode,
46 supportsClaudeThinkingToggle,
47 supportsClaudeUltrathinkKeyword,
48} from "@t3tools/shared/model";
49import {
50 Cause,
51 DateTime,
52 Deferred,
53 Effect,
54 Exit,
55 FileSystem,
56 Fiber,
57 Layer,
58 Queue,
59 Random,
60 Ref,
61 Stream,
62} from "effect";
63
64import { resolveAttachmentPath } from "../../attachmentStore.ts";
65import { ServerConfig } from "../../config.ts";
66import {
67 ProviderAdapterProcessError,
68 ProviderAdapterRequestError,
69 ProviderAdapterSessionClosedError,
70 ProviderAdapterSessionNotFoundError,
71 ProviderAdapterValidationError,
72 type ProviderAdapterError,
73} from "../Errors.ts";
74import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
75import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
76
77const PROVIDER = "claudeAgent" as const;
78type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
79type ClaudeToolResultStreamKind = Extract<
80 RuntimeContentStreamKind,
81 "command_output" | "file_change_output"
82>;
83
84type PromptQueueItem =
85 | {
86 readonly type: "message";
87 readonly message: SDKUserMessage;
88 }
89 | {
90 readonly type: "terminate";
91 };
92
93interface ClaudeResumeState {
94 readonly threadId?: ThreadId;
95 readonly resume?: string;
96 readonly resumeSessionAt?: string;
97 readonly turnCount?: number;
98}
99
100interface ClaudeTurnState {
101 readonly turnId: TurnId;
102 readonly startedAt: string;
103 readonly items: Array<unknown>;
104 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
105 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
106 readonly capturedProposedPlanKeys: Set<string>;
107 nextSyntheticAssistantBlockIndex: number;
108}
109
110interface AssistantTextBlockState {
111 readonly itemId: string;
112 readonly blockIndex: number;
113 emittedTextDelta: boolean;
114 fallbackText: string;
115 streamClosed: boolean;
116 completionEmitted: boolean;
117}
118
119interface PendingApproval {
120 readonly requestType: CanonicalRequestType;
121 readonly detail?: string;
122 readonly suggestions?: ReadonlyArray<PermissionUpdate>;
123 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
124}
125
126interface PendingUserInput {
127 readonly questions: ReadonlyArray<UserInputQuestion>;
128 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
129}
130
131interface ToolInFlight {
132 readonly itemId: string;
133 readonly itemType: CanonicalItemType;
134 readonly toolName: string;
135 readonly title: string;
136 readonly detail?: string;
137 readonly input: Record<string, unknown>;
138 readonly partialInputJson: string;
139 readonly lastEmittedInputFingerprint?: string;
140}
141
142interface ClaudeSessionContext {
143 session: ProviderSession;
144 readonly promptQueue: Queue.Queue<PromptQueueItem>;
145 readonly query: ClaudeQueryRuntime;
146 streamFiber: Fiber.Fiber<void, Error> | undefined;
147 readonly startedAt: string;
148 readonly basePermissionMode: PermissionMode | undefined;
149 resumeSessionId: string | undefined;
150 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
151 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
152 readonly turns: Array<{
153 id: TurnId;
154 items: Array<unknown>;
155 }>;
156 readonly inFlightTools: Map<number, ToolInFlight>;
157 turnState: ClaudeTurnState | undefined;
158 lastAssistantUuid: string | undefined;
159 lastThreadStartedId: string | undefined;
160 stopped: boolean;
161}
162
163interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
164 readonly interrupt: () => Promise<void>;
165 readonly setModel: (model?: string) => Promise<void>;
166 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
167 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
168 readonly close: () => void;
169}
170
171export interface ClaudeAdapterLiveOptions {
172 readonly createQuery?: (input: {
173 readonly prompt: AsyncIterable<SDKUserMessage>;
174 readonly options: ClaudeQueryOptions;
175 }) => ClaudeQueryRuntime;
176 readonly nativeEventLogPath?: string;
177 readonly nativeEventLogger?: EventNdjsonLogger;
178}
179
180function isUuid(value: string): boolean {
181 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);
182}
183
184function isSyntheticClaudeThreadId(value: string): boolean {
185 return value.startsWith("claude-thread-");
186}
187
188function toMessage(cause: unknown, fallback: string): string {
189 if (cause instanceof Error && cause.message.length > 0) {
190 return cause.message;
191 }
192 return fallback;
193}
194
195function toError(cause: unknown, fallback: string): Error {
196 return cause instanceof Error ? cause : new Error(toMessage(cause, fallback));
197}
198
199function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray<string> {
200 const errors = Cause.prettyErrors(cause)
201 .map((error) => error.message.trim())
202 .filter((message) => message.length > 0);
203 if (errors.length > 0) {
204 return errors;
205 }
206
207 const squashed = toMessage(Cause.squash(cause), "").trim();
208 return squashed.length > 0 ? [squashed] : [];
209}
210
211function isClaudeInterruptedMessage(message: string): boolean {
212 const normalized = message.toLowerCase();
213 return (
214 normalized.includes("all fibers interrupted without error") ||
215 normalized.includes("request was aborted") ||
216 normalized.includes("interrupted by user")
217 );
218}
219
220function isClaudeInterruptedCause(cause: Cause.Cause<Error>): boolean {
221 return (
222 Cause.hasInterruptsOnly(cause) ||
223 normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage)
224 );
225}
226
227function messageFromClaudeStreamCause(cause: Cause.Cause<Error>, fallback: string): string {
228 return normalizeClaudeStreamMessages(cause)[0] ?? fallback;
229}
230
231function interruptionMessageFromClaudeCause(cause: Cause.Cause<Error>): string {
232 const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted.");
233 return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message;
234}
235
236function resultErrorsText(result: SDKResultMessage): string {
237 return "errors" in result && Array.isArray(result.errors)
238 ? result.errors.join(" ").toLowerCase()
239 : "";
240}
241
242function isInterruptedResult(result: SDKResultMessage): boolean {
243 const errors = resultErrorsText(result);
244 if (errors.includes("interrupt")) {
245 return true;
246 }
247
248 return (
249 result.subtype === "error_during_execution" &&
250 result.is_error === false &&
251 (errors.includes("request was aborted") ||
252 errors.includes("interrupted by user") ||
253 errors.includes("aborted"))
254 );
255}
256
257function asRuntimeItemId(value: string): RuntimeItemId {
258 return RuntimeItemId.makeUnsafe(value);
259}
260
261function asCanonicalTurnId(value: TurnId): TurnId {
262 return value;
263}
264
265function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
266 return RuntimeRequestId.makeUnsafe(value);
267}
268
269function toPermissionMode(value: unknown): PermissionMode | undefined {
270 switch (value) {
271 case "default":
272 case "acceptEdits":
273 case "bypassPermissions":
274 case "plan":
275 case "dontAsk":
276 return value;
277 default:
278 return undefined;
279 }
280}
281
282function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined {
283 if (!resumeCursor || typeof resumeCursor !== "object") {
284 return undefined;
285 }
286 const cursor = resumeCursor as {
287 threadId?: unknown;
288 resume?: unknown;
289 sessionId?: unknown;
290 resumeSessionAt?: unknown;
291 turnCount?: unknown;
292 };
293
294 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
295 const threadId =
296 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
297 ? ThreadId.makeUnsafe(threadIdCandidate)
298 : undefined;
299 const resumeCandidate =
300 typeof cursor.resume === "string"
301 ? cursor.resume
302 : typeof cursor.sessionId === "string"
303 ? cursor.sessionId
304 : undefined;
305 const resume = resumeCandidate && isUuid(resumeCandidate) ? resumeCandidate : undefined;
306 const resumeSessionAt =
307 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
308 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
309
310 return {
311 ...(threadId ? { threadId } : {}),
312 ...(resume ? { resume } : {}),
313 ...(resumeSessionAt ? { resumeSessionAt } : {}),
314 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
315 ? { turnCount: turnCountValue }
316 : {}),
317 };
318}
319
320function classifyToolItemType(toolName: string): CanonicalItemType {
321 const normalized = toolName.toLowerCase();
322 if (normalized.includes("agent")) {
323 return "collab_agent_tool_call";
324 }
325 if (
326 normalized === "task" ||
327 normalized === "agent" ||
328 normalized.includes("subagent") ||
329 normalized.includes("sub-agent")
330 ) {
331 return "collab_agent_tool_call";
332 }
333 if (
334 normalized.includes("bash") ||
335 normalized.includes("command") ||
336 normalized.includes("shell") ||
337 normalized.includes("terminal")
338 ) {
339 return "command_execution";
340 }
341 if (
342 normalized.includes("edit") ||
343 normalized.includes("write") ||
344 normalized.includes("file") ||
345 normalized.includes("patch") ||
346 normalized.includes("replace") ||
347 normalized.includes("create") ||
348 normalized.includes("delete")
349 ) {
350 return "file_change";
351 }
352 if (normalized.includes("mcp")) {
353 return "mcp_tool_call";
354 }
355 if (normalized.includes("websearch") || normalized.includes("web search")) {
356 return "web_search";
357 }
358 if (normalized.includes("image")) {
359 return "image_view";
360 }
361 return "dynamic_tool_call";
362}
363
364function isReadOnlyToolName(toolName: string): boolean {
365 const normalized = toolName.toLowerCase();
366 return (
367 normalized === "read" ||
368 normalized.includes("read file") ||
369 normalized.includes("view") ||
370 normalized.includes("grep") ||
371 normalized.includes("glob") ||
372 normalized.includes("search")
373 );
374}
375
376function classifyRequestType(toolName: string): CanonicalRequestType {
377 if (isReadOnlyToolName(toolName)) {
378 return "file_read_approval";
379 }
380 const itemType = classifyToolItemType(toolName);
381 return itemType === "command_execution"
382 ? "command_execution_approval"
383 : itemType === "file_change"
384 ? "file_change_approval"
385 : "dynamic_tool_call";
386}
387
388function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
389 const commandValue = input.command ?? input.cmd;
390 const command = typeof commandValue === "string" ? commandValue : undefined;
391 if (command && command.trim().length > 0) {
392 return `${toolName}: ${command.trim().slice(0, 400)}`;
393 }
394
395 const serialized = JSON.stringify(input);
396 if (serialized.length <= 400) {
397 return `${toolName}: ${serialized}`;
398 }
399 return `${toolName}: ${serialized.slice(0, 397)}...`;
400}
401
402function titleForTool(itemType: CanonicalItemType): string {
403 switch (itemType) {
404 case "command_execution":
405 return "Command run";
406 case "file_change":
407 return "File change";
408 case "mcp_tool_call":
409 return "MCP tool call";
410 case "collab_agent_tool_call":
411 return "Subagent task";
412 case "web_search":
413 return "Web search";
414 case "image_view":
415 return "Image view";
416 case "dynamic_tool_call":
417 return "Tool call";
418 default:
419 return "Item";
420 }
421}
422
423const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([
424 "image/gif",
425 "image/jpeg",
426 "image/png",
427 "image/webp",
428]);
429
430function buildPromptText(input: ProviderSendTurnInput): string {
431 const requestedEffort = resolveReasoningEffortForProvider(
432 "claudeAgent",
433 input.modelOptions?.claudeAgent?.effort ?? null,
434 );
435 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
436 const promptEffort =
437 requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model)
438 ? "ultrathink"
439 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
440 ? requestedEffort
441 : null;
442 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
443}
444
445function buildUserMessage(input: {
446 readonly sdkContent: Array<Record<string, unknown>>;
447}): SDKUserMessage {
448 return {
449 type: "user",
450 session_id: "",
451 parent_tool_use_id: null,
452 message: {
453 role: "user",
454 content: input.sdkContent,
455 },
456 } as SDKUserMessage;
457}
458
459function buildClaudeImageContentBlock(input: {
460 readonly mimeType: string;
461 readonly bytes: Uint8Array;
462}): Record<string, unknown> {
463 return {
464 type: "image",
465 source: {
466 type: "base64",
467 media_type: input.mimeType,
468 data: Buffer.from(input.bytes).toString("base64"),
469 },
470 };
471}
472
473function buildUserMessageEffect(
474 input: ProviderSendTurnInput,
475 dependencies: {
476 readonly fileSystem: FileSystem.FileSystem;
477 readonly stateDir: string;
478 },
479): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
480 return Effect.gen(function* () {
481 const text = buildPromptText(input);
482 const sdkContent: Array<Record<string, unknown>> = [];
483
484 if (text.length > 0) {
485 sdkContent.push({ type: "text", text });
486 }
487
488 for (const attachment of input.attachments ?? []) {
489 if (attachment.type !== "image") {
490 continue;
491 }
492
493 if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) {
494 return yield* new ProviderAdapterRequestError({
495 provider: PROVIDER,
496 method: "turn/start",
497 detail: `Unsupported Claude image attachment type '${attachment.mimeType}'.`,
498 });
499 }
500
501 const attachmentPath = resolveAttachmentPath({
502 stateDir: dependencies.stateDir,
503 attachment,
504 });
505 if (!attachmentPath) {
506 return yield* new ProviderAdapterRequestError({
507 provider: PROVIDER,
508 method: "turn/start",
509 detail: `Invalid attachment id '${attachment.id}'.`,
510 });
511 }
512
513 const bytes = yield* dependencies.fileSystem.readFile(attachmentPath).pipe(
514 Effect.mapError(
515 (cause) =>
516 new ProviderAdapterRequestError({
517 provider: PROVIDER,
518 method: "turn/start",
519 detail: toMessage(cause, "Failed to read attachment file."),
520 cause,
521 }),
522 ),
523 );
524
525 sdkContent.push(
526 buildClaudeImageContentBlock({
527 mimeType: attachment.mimeType,
528 bytes,
529 }),
530 );
531 }
532
533 return buildUserMessage({ sdkContent });
534 });
535}
536
537function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
538 if (result.subtype === "success") {
539 return "completed";
540 }
541
542 const errors = resultErrorsText(result);
543 if (isInterruptedResult(result)) {
544 return "interrupted";
545 }
546 if (errors.includes("cancel")) {
547 return "cancelled";
548 }
549 return "failed";
550}
551
552function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {
553 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";
554}
555
556function nativeProviderRefs(
557 _context: ClaudeSessionContext,
558 options?: {
559 readonly providerItemId?: string | undefined;
560 },
561): NonNullable<ProviderRuntimeEvent["providerRefs"]> {
562 if (options?.providerItemId) {
563 return {
564 providerItemId: ProviderItemId.makeUnsafe(options.providerItemId),
565 };
566 }
567 return {};
568}
569
570function extractAssistantTextBlocks(message: SDKMessage): Array<string> {
571 if (message.type !== "assistant") {
572 return [];
573 }
574
575 const content = (message.message as { content?: unknown } | undefined)?.content;
576 if (!Array.isArray(content)) {
577 return [];
578 }
579
580 const fragments: string[] = [];
581 for (const block of content) {
582 if (!block || typeof block !== "object") {
583 continue;
584 }
585 const candidate = block as { type?: unknown; text?: unknown };
586 if (
587 candidate.type === "text" &&
588 typeof candidate.text === "string" &&
589 candidate.text.length > 0
590 ) {
591 fragments.push(candidate.text);
592 }
593 }
594
595 return fragments;
596}
597
598function extractContentBlockText(block: unknown): string {
599 if (!block || typeof block !== "object") {
600 return "";
601 }
602
603 const candidate = block as { type?: unknown; text?: unknown };
604 return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
605}
606
607function extractTextContent(value: unknown): string {
608 if (typeof value === "string") {
609 return value;
610 }
611
612 if (Array.isArray(value)) {
613 return value.map((entry) => extractTextContent(entry)).join("");
614 }
615
616 if (!value || typeof value !== "object") {
617 return "";
618 }
619
620 const record = value as {
621 text?: unknown;
622 content?: unknown;
623 };
624
625 if (typeof record.text === "string") {
626 return record.text;
627 }
628
629 return extractTextContent(record.content);
630}
631
632function extractExitPlanModePlan(value: unknown): string | undefined {
633 if (!value || typeof value !== "object") {
634 return undefined;
635 }
636
637 const record = value as {
638 plan?: unknown;
639 };
640 return typeof record.plan === "string" && record.plan.trim().length > 0
641 ? record.plan.trim()
642 : undefined;
643}
644
645function exitPlanCaptureKey(input: {
646 readonly toolUseId?: string | undefined;
647 readonly planMarkdown: string;
648}): string {
649 return input.toolUseId && input.toolUseId.length > 0
650 ? `tool:${input.toolUseId}`
651 : `plan:${input.planMarkdown}`;
652}
653
654function tryParseJsonRecord(value: string): Record<string, unknown> | undefined {
655 try {
656 const parsed = JSON.parse(value);
657 return parsed && typeof parsed === "object" && !Array.isArray(parsed)
658 ? (parsed as Record<string, unknown>)
659 : undefined;
660 } catch {
661 return undefined;
662 }
663}
664
665function toolInputFingerprint(input: Record<string, unknown>): string | undefined {
666 try {
667 return JSON.stringify(input);
668 } catch {
669 return undefined;
670 }
671}
672
673function toolResultStreamKind(itemType: CanonicalItemType): ClaudeToolResultStreamKind | undefined {
674 switch (itemType) {
675 case "command_execution":
676 return "command_output";
677 case "file_change":
678 return "file_change_output";
679 default:
680 return undefined;
681 }
682}
683
684function toolResultBlocksFromUserMessage(message: SDKMessage): Array<{
685 readonly toolUseId: string;
686 readonly block: Record<string, unknown>;
687 readonly text: string;
688 readonly isError: boolean;
689}> {
690 if (message.type !== "user") {
691 return [];
692 }
693
694 const content = (message.message as { content?: unknown } | undefined)?.content;
695 if (!Array.isArray(content)) {
696 return [];
697 }
698
699 const blocks: Array<{
700 readonly toolUseId: string;
701 readonly block: Record<string, unknown>;
702 readonly text: string;
703 readonly isError: boolean;
704 }> = [];
705
706 for (const entry of content) {
707 if (!entry || typeof entry !== "object") {
708 continue;
709 }
710
711 const block = entry as Record<string, unknown>;
712 if (block.type !== "tool_result") {
713 continue;
714 }
715
716 const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
717 if (!toolUseId) {
718 continue;
719 }
720
721 blocks.push({
722 toolUseId,
723 block,
724 text: extractTextContent(block.content),
725 isError: block.is_error === true,
726 });
727 }
728
729 return blocks;
730}
731
732function toSessionError(
733 threadId: ThreadId,
734 cause: unknown,
735): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
736 const normalized = toMessage(cause, "").toLowerCase();
737 if (normalized.includes("unknown session") || normalized.includes("not found")) {
738 return new ProviderAdapterSessionNotFoundError({
739 provider: PROVIDER,
740 threadId,
741 cause,
742 });
743 }
744 if (normalized.includes("closed")) {
745 return new ProviderAdapterSessionClosedError({
746 provider: PROVIDER,
747 threadId,
748 cause,
749 });
750 }
751 return undefined;
752}
753
754function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError {
755 const sessionError = toSessionError(threadId, cause);
756 if (sessionError) {
757 return sessionError;
758 }
759 return new ProviderAdapterRequestError({
760 provider: PROVIDER,
761 method,
762 detail: toMessage(cause, `${method} failed`),
763 cause,
764 });
765}
766
767function sdkMessageType(value: unknown): string | undefined {
768 if (!value || typeof value !== "object") {
769 return undefined;
770 }
771 const record = value as { type?: unknown };
772 return typeof record.type === "string" ? record.type : undefined;
773}
774
775function sdkMessageSubtype(value: unknown): string | undefined {
776 if (!value || typeof value !== "object") {
777 return undefined;
778 }
779 const record = value as { subtype?: unknown };
780 return typeof record.subtype === "string" ? record.subtype : undefined;
781}
782
783function sdkNativeMethod(message: SDKMessage): string {
784 const subtype = sdkMessageSubtype(message);
785 if (subtype) {
786 return `claude/${message.type}/${subtype}`;
787 }
788
789 if (message.type === "stream_event") {
790 const streamType = sdkMessageType(message.event);
791 if (streamType) {
792 const deltaType =
793 streamType === "content_block_delta"
794 ? sdkMessageType((message.event as { delta?: unknown }).delta)
795 : undefined;
796 if (deltaType) {
797 return `claude/${message.type}/${streamType}/${deltaType}`;
798 }
799 return `claude/${message.type}/${streamType}`;
800 }
801 }
802
803 return `claude/${message.type}`;
804}
805
806function sdkNativeItemId(message: SDKMessage): string | undefined {
807 if (message.type === "assistant") {
808 const maybeId = (message.message as { id?: unknown }).id;
809 if (typeof maybeId === "string") {
810 return maybeId;
811 }
812 return undefined;
813 }
814
815 if (message.type === "user") {
816 return toolResultBlocksFromUserMessage(message)[0]?.toolUseId;
817 }
818
819 if (message.type === "stream_event") {
820 const event = message.event as {
821 type?: unknown;
822 content_block?: { id?: unknown };
823 };
824 if (event.type === "content_block_start" && typeof event.content_block?.id === "string") {
825 return event.content_block.id;
826 }
827 }
828
829 return undefined;
830}
831
832function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
833 return Effect.gen(function* () {
834 const fileSystem = yield* FileSystem.FileSystem;
835 const serverConfig = yield* ServerConfig;
836 const nativeEventLogger =
837 options?.nativeEventLogger ??
838 (options?.nativeEventLogPath !== undefined
839 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
840 stream: "native",
841 })
842 : undefined);
843
844 const createQuery =
845 options?.createQuery ??
846 ((input: {
847 readonly prompt: AsyncIterable<SDKUserMessage>;
848 readonly options: ClaudeQueryOptions;
849 }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);
850
851 const sessions = new Map<ThreadId, ClaudeSessionContext>();
852 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
853
854 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
855 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
856 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
857
858 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
859 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
860
861 const logNativeSdkMessage = (
862 context: ClaudeSessionContext,
863 message: SDKMessage,
864 ): Effect.Effect<void> =>
865 Effect.gen(function* () {
866 if (!nativeEventLogger) {
867 return;
868 }
869
870 const observedAt = new Date().toISOString();
871 const itemId = sdkNativeItemId(message);
872
873 yield* nativeEventLogger.write(
874 {
875 observedAt,
876 event: {
877 id:
878 "uuid" in message && typeof message.uuid === "string"
879 ? message.uuid
880 : crypto.randomUUID(),
881 kind: "notification",
882 provider: PROVIDER,
883 createdAt: observedAt,
884 method: sdkNativeMethod(message),
885 ...(typeof message.session_id === "string"
886 ? { providerThreadId: message.session_id }
887 : {}),
888 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
889 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),
890 payload: message,
891 },
892 },
893 context.session.threadId,
894 );
895 });
896
897 const snapshotThread = (
898 context: ClaudeSessionContext,
899 ): Effect.Effect<
900 {
901 threadId: ThreadId;
902 turns: ReadonlyArray<{
903 id: TurnId;
904 items: ReadonlyArray<unknown>;
905 }>;
906 },
907 ProviderAdapterValidationError
908 > =>
909 Effect.gen(function* () {
910 const threadId = context.session.threadId;
911 if (!threadId) {
912 return yield* new ProviderAdapterValidationError({
913 provider: PROVIDER,
914 operation: "readThread",
915 issue: "Session thread id is not initialized yet.",
916 });
917 }
918 return {
919 threadId,
920 turns: context.turns.map((turn) => ({
921 id: turn.id,
922 items: [...turn.items],
923 })),
924 };
925 });
926
927 const updateResumeCursor = (context: ClaudeSessionContext): Effect.Effect<void> =>
928 Effect.gen(function* () {
929 const threadId = context.session.threadId;
930 if (!threadId) return;
931
932 const resumeCursor = {
933 threadId,
934 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
935 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
936 turnCount: context.turns.length,
937 };
938
939 context.session = {
940 ...context.session,
941 resumeCursor,
942 updatedAt: yield* nowIso,
943 };
944 });
945
946 const ensureAssistantTextBlock = (
947 context: ClaudeSessionContext,
948 blockIndex: number,
949 options?: {
950 readonly fallbackText?: string;
951 readonly streamClosed?: boolean;
952 },
953 ): Effect.Effect<
954 | {
955 readonly blockIndex: number;
956 readonly block: AssistantTextBlockState;
957 }
958 | undefined
959 > =>
960 Effect.gen(function* () {
961 const turnState = context.turnState;
962 if (!turnState) {
963 return undefined;
964 }
965
966 const existing = turnState.assistantTextBlocks.get(blockIndex);
967 if (existing && !existing.completionEmitted) {
968 if (existing.fallbackText.length === 0 && options?.fallbackText) {
969 existing.fallbackText = options.fallbackText;
970 }
971 if (options?.streamClosed) {
972 existing.streamClosed = true;
973 }
974 return { blockIndex, block: existing };
975 }
976
977 const block: AssistantTextBlockState = {
978 itemId: yield* Random.nextUUIDv4,
979 blockIndex,
980 emittedTextDelta: false,
981 fallbackText: options?.fallbackText ?? "",
982 streamClosed: options?.streamClosed ?? false,
983 completionEmitted: false,
984 };
985 turnState.assistantTextBlocks.set(blockIndex, block);
986 turnState.assistantTextBlockOrder.push(block);
987 return { blockIndex, block };
988 });
989
990 const createSyntheticAssistantTextBlock = (
991 context: ClaudeSessionContext,
992 fallbackText: string,
993 ): Effect.Effect<
994 | {
995 readonly blockIndex: number;
996 readonly block: AssistantTextBlockState;
997 }
998 | undefined
999 > =>
1000 Effect.gen(function* () {
1001 const turnState = context.turnState;
1002 if (!turnState) {
1003 return undefined;
1004 }
1005
1006 const blockIndex = turnState.nextSyntheticAssistantBlockIndex;
1007 turnState.nextSyntheticAssistantBlockIndex -= 1;
1008 return yield* ensureAssistantTextBlock(context, blockIndex, {
1009 fallbackText,
1010 streamClosed: true,
1011 });
1012 });
1013
1014 const completeAssistantTextBlock = (
1015 context: ClaudeSessionContext,
1016 block: AssistantTextBlockState,
1017 options?: {
1018 readonly force?: boolean;
1019 readonly rawMethod?: string;
1020 readonly rawPayload?: unknown;
1021 },
1022 ): Effect.Effect<void> =>
1023 Effect.gen(function* () {
1024 const turnState = context.turnState;
1025 if (!turnState || block.completionEmitted) {
1026 return;
1027 }
1028
1029 if (!options?.force && !block.streamClosed) {
1030 return;
1031 }
1032
1033 if (!block.emittedTextDelta && block.fallbackText.length > 0) {
1034 const deltaStamp = yield* makeEventStamp();
1035 yield* offerRuntimeEvent({
1036 type: "content.delta",
1037 eventId: deltaStamp.eventId,
1038 provider: PROVIDER,
1039 createdAt: deltaStamp.createdAt,
1040 threadId: context.session.threadId,
1041 turnId: turnState.turnId,
1042 itemId: asRuntimeItemId(block.itemId),
1043 payload: {
1044 streamKind: "assistant_text",
1045 delta: block.fallbackText,
1046 },
1047 providerRefs: nativeProviderRefs(context),
1048 ...(options?.rawMethod || options?.rawPayload
1049 ? {
1050 raw: {
1051 source: "claude.sdk.message" as const,
1052 ...(options.rawMethod ? { method: options.rawMethod } : {}),
1053 payload: options?.rawPayload,
1054 },
1055 }
1056 : {}),
1057 });
1058 }
1059
1060 block.completionEmitted = true;
1061 if (turnState.assistantTextBlocks.get(block.blockIndex) === block) {
1062 turnState.assistantTextBlocks.delete(block.blockIndex);
1063 }
1064
1065 const stamp = yield* makeEventStamp();
1066 yield* offerRuntimeEvent({
1067 type: "item.completed",
1068 eventId: stamp.eventId,
1069 provider: PROVIDER,
1070 createdAt: stamp.createdAt,
1071 itemId: asRuntimeItemId(block.itemId),
1072 threadId: context.session.threadId,
1073 turnId: turnState.turnId,
1074 payload: {
1075 itemType: "assistant_message",
1076 status: "completed",
1077 title: "Assistant message",
1078 ...(block.fallbackText.length > 0 ? { detail: block.fallbackText } : {}),
1079 },
1080 providerRefs: nativeProviderRefs(context),
1081 ...(options?.rawMethod || options?.rawPayload
1082 ? {
1083 raw: {
1084 source: "claude.sdk.message" as const,
1085 ...(options.rawMethod ? { method: options.rawMethod } : {}),
1086 payload: options?.rawPayload,
1087 },
1088 }
1089 : {}),
1090 });
1091 });
1092
1093 const backfillAssistantTextBlocksFromSnapshot = (
1094 context: ClaudeSessionContext,
1095 message: SDKMessage,
1096 ): Effect.Effect<void> =>
1097 Effect.gen(function* () {
1098 const turnState = context.turnState;
1099 if (!turnState) {
1100 return;
1101 }
1102
1103 const snapshotTextBlocks = extractAssistantTextBlocks(message);
1104 if (snapshotTextBlocks.length === 0) {
1105 return;
1106 }
1107
1108 const orderedBlocks = turnState.assistantTextBlockOrder.map((block) => ({
1109 blockIndex: block.blockIndex,
1110 block,
1111 }));
1112
1113 for (const [position, text] of snapshotTextBlocks.entries()) {
1114 const existingEntry = orderedBlocks[position];
1115 const entry =
1116 existingEntry ??
1117 (yield* createSyntheticAssistantTextBlock(context, text).pipe(
1118 Effect.map((created) => {
1119 if (!created) {
1120 return undefined;
1121 }
1122 orderedBlocks.push(created);
1123 return created;
1124 }),
1125 ));
1126 if (!entry) {
1127 continue;
1128 }
1129
1130 if (entry.block.fallbackText.length === 0) {
1131 entry.block.fallbackText = text;
1132 }
1133
1134 if (entry.block.streamClosed && !entry.block.completionEmitted) {
1135 yield* completeAssistantTextBlock(context, entry.block, {
1136 rawMethod: "claude/assistant",
1137 rawPayload: message,
1138 });
1139 }
1140 }
1141 });
1142
1143 const ensureThreadId = (
1144 context: ClaudeSessionContext,
1145 message: SDKMessage,
1146 ): Effect.Effect<void> =>
1147 Effect.gen(function* () {
1148 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
1149 return;
1150 }
1151 const nextThreadId = message.session_id;
1152 context.resumeSessionId = message.session_id;
1153 yield* updateResumeCursor(context);
1154
1155 if (context.lastThreadStartedId !== nextThreadId) {
1156 context.lastThreadStartedId = nextThreadId;
1157 const stamp = yield* makeEventStamp();
1158 yield* offerRuntimeEvent({
1159 type: "thread.started",
1160 eventId: stamp.eventId,
1161 provider: PROVIDER,
1162 createdAt: stamp.createdAt,
1163 threadId: context.session.threadId,
1164 payload: {
1165 providerThreadId: nextThreadId,
1166 },
1167 providerRefs: {},
1168 raw: {
1169 source: "claude.sdk.message",
1170 method: "claude/thread/started",
1171 payload: {
1172 session_id: message.session_id,
1173 },
1174 },
1175 });
1176 }
1177 });
1178
1179 const emitRuntimeError = (
1180 context: ClaudeSessionContext,
1181 message: string,
1182 cause?: unknown,
1183 ): Effect.Effect<void> =>
1184 Effect.gen(function* () {
1185 if (cause !== undefined) {
1186 void cause;
1187 }
1188 const turnState = context.turnState;
1189 const stamp = yield* makeEventStamp();
1190 yield* offerRuntimeEvent({
1191 type: "runtime.error",
1192 eventId: stamp.eventId,
1193 provider: PROVIDER,
1194 createdAt: stamp.createdAt,
1195 threadId: context.session.threadId,
1196 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
1197 payload: {
1198 message,
1199 class: "provider_error",
1200 ...(cause !== undefined ? { detail: cause } : {}),
1201 },
1202 providerRefs: nativeProviderRefs(context),
1203 });
1204 });
1205
1206 const emitRuntimeWarning = (
1207 context: ClaudeSessionContext,
1208 message: string,
1209 detail?: unknown,
1210 ): Effect.Effect<void> =>
1211 Effect.gen(function* () {
1212 const turnState = context.turnState;
1213 const stamp = yield* makeEventStamp();
1214 yield* offerRuntimeEvent({
1215 type: "runtime.warning",
1216 eventId: stamp.eventId,
1217 provider: PROVIDER,
1218 createdAt: stamp.createdAt,
1219 threadId: context.session.threadId,
1220 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
1221 payload: {
1222 message,
1223 ...(detail !== undefined ? { detail } : {}),
1224 },
1225 providerRefs: nativeProviderRefs(context),
1226 });
1227 });
1228
1229 const emitProposedPlanCompleted = (
1230 context: ClaudeSessionContext,
1231 input: {
1232 readonly planMarkdown: string;
1233 readonly toolUseId?: string | undefined;
1234 readonly rawSource: "claude.sdk.message" | "claude.sdk.permission";
1235 readonly rawMethod: string;
1236 readonly rawPayload: unknown;
1237 },
1238 ): Effect.Effect<void> =>
1239 Effect.gen(function* () {
1240 const turnState = context.turnState;
1241 const planMarkdown = input.planMarkdown.trim();
1242 if (!turnState || planMarkdown.length === 0) {
1243 return;
1244 }
1245
1246 const captureKey = exitPlanCaptureKey({
1247 toolUseId: input.toolUseId,
1248 planMarkdown,
1249 });
1250 if (turnState.capturedProposedPlanKeys.has(captureKey)) {
1251 return;
1252 }
1253 turnState.capturedProposedPlanKeys.add(captureKey);
1254
1255 const stamp = yield* makeEventStamp();
1256 yield* offerRuntimeEvent({
1257 type: "turn.proposed.completed",
1258 eventId: stamp.eventId,
1259 provider: PROVIDER,
1260 createdAt: stamp.createdAt,
1261 threadId: context.session.threadId,
1262 turnId: turnState.turnId,
1263 payload: {
1264 planMarkdown,
1265 },
1266 providerRefs: nativeProviderRefs(context, {
1267 providerItemId: input.toolUseId,
1268 }),
1269 raw: {
1270 source: input.rawSource,
1271 method: input.rawMethod,
1272 payload: input.rawPayload,
1273 },
1274 });
1275 });
1276
1277 const completeTurn = (
1278 context: ClaudeSessionContext,
1279 status: ProviderRuntimeTurnStatus,
1280 errorMessage?: string,
1281 result?: SDKResultMessage,
1282 ): Effect.Effect<void> =>
1283 Effect.gen(function* () {
1284 const turnState = context.turnState;
1285 if (!turnState) {
1286 const stamp = yield* makeEventStamp();
1287 yield* offerRuntimeEvent({
1288 type: "turn.completed",
1289 eventId: stamp.eventId,
1290 provider: PROVIDER,
1291 createdAt: stamp.createdAt,
1292 threadId: context.session.threadId,
1293 payload: {
1294 state: status,
1295 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
1296 ...(result?.usage ? { usage: result.usage } : {}),
1297 ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}),
1298 ...(typeof result?.total_cost_usd === "number"
1299 ? { totalCostUsd: result.total_cost_usd }
1300 : {}),
1301 ...(errorMessage ? { errorMessage } : {}),
1302 },
1303 providerRefs: {},
1304 });
1305 return;
1306 }
1307
1308 for (const [index, tool] of context.inFlightTools.entries()) {
1309 const toolStamp = yield* makeEventStamp();
1310 yield* offerRuntimeEvent({
1311 type: "item.completed",
1312 eventId: toolStamp.eventId,
1313 provider: PROVIDER,
1314 createdAt: toolStamp.createdAt,
1315 threadId: context.session.threadId,
1316 turnId: turnState.turnId,
1317 itemId: asRuntimeItemId(tool.itemId),
1318 payload: {
1319 itemType: tool.itemType,
1320 status: status === "completed" ? "completed" : "failed",
1321 title: tool.title,
1322 ...(tool.detail ? { detail: tool.detail } : {}),
1323 data: {
1324 toolName: tool.toolName,
1325 input: tool.input,
1326 },
1327 },
1328 providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
1329 raw: {
1330 source: "claude.sdk.message",
1331 method: "claude/result",
1332 payload: result ?? { status },
1333 },
1334 });
1335 context.inFlightTools.delete(index);
1336 }
1337 // Clear any remaining stale entries (e.g. from interrupted content blocks)
1338 context.inFlightTools.clear();
1339
1340 for (const block of turnState.assistantTextBlockOrder) {
1341 yield* completeAssistantTextBlock(context, block, {
1342 force: true,
1343 rawMethod: "claude/result",
1344 rawPayload: result ?? { status },
1345 });
1346 }
1347
1348 context.turns.push({
1349 id: turnState.turnId,
1350 items: [...turnState.items],
1351 });
1352
1353 const stamp = yield* makeEventStamp();
1354 yield* offerRuntimeEvent({
1355 type: "turn.completed",
1356 eventId: stamp.eventId,
1357 provider: PROVIDER,
1358 createdAt: stamp.createdAt,
1359 threadId: context.session.threadId,
1360 turnId: turnState.turnId,
1361 payload: {
1362 state: status,
1363 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
1364 ...(result?.usage ? { usage: result.usage } : {}),
1365 ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}),
1366 ...(typeof result?.total_cost_usd === "number"
1367 ? { totalCostUsd: result.total_cost_usd }
1368 : {}),
1369 ...(errorMessage ? { errorMessage } : {}),
1370 },
1371 providerRefs: nativeProviderRefs(context),
1372 });
1373
1374 const updatedAt = yield* nowIso;
1375 context.turnState = undefined;
1376 context.session = {
1377 ...context.session,
1378 status: "ready",
1379 activeTurnId: undefined,
1380 updatedAt,
1381 ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}),
1382 };
1383 yield* updateResumeCursor(context);
1384 });
1385
1386 const handleStreamEvent = (
1387 context: ClaudeSessionContext,
1388 message: SDKMessage,
1389 ): Effect.Effect<void> =>
1390 Effect.gen(function* () {
1391 if (message.type !== "stream_event") {
1392 return;
1393 }
1394
1395 const { event } = message;
1396
1397 if (event.type === "content_block_delta") {
1398 if (
1399 (event.delta.type === "text_delta" || event.delta.type === "thinking_delta") &&
1400 context.turnState
1401 ) {
1402 const deltaText =
1403 event.delta.type === "text_delta"
1404 ? event.delta.text
1405 : typeof event.delta.thinking === "string"
1406 ? event.delta.thinking
1407 : "";
1408 if (deltaText.length === 0) {
1409 return;
1410 }
1411 const streamKind = streamKindFromDeltaType(event.delta.type);
1412 const assistantBlockEntry =
1413 event.delta.type === "text_delta"
1414 ? yield* ensureAssistantTextBlock(context, event.index)
1415 : context.turnState.assistantTextBlocks.get(event.index)
1416 ? {
1417 blockIndex: event.index,
1418 block: context.turnState.assistantTextBlocks.get(
1419 event.index,
1420 ) as AssistantTextBlockState,
1421 }
1422 : undefined;
1423 if (assistantBlockEntry?.block && event.delta.type === "text_delta") {
1424 assistantBlockEntry.block.emittedTextDelta = true;
1425 }
1426 const stamp = yield* makeEventStamp();
1427 yield* offerRuntimeEvent({
1428 type: "content.delta",
1429 eventId: stamp.eventId,
1430 provider: PROVIDER,
1431 createdAt: stamp.createdAt,
1432 threadId: context.session.threadId,
1433 turnId: context.turnState.turnId,
1434 ...(assistantBlockEntry?.block
1435 ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) }
1436 : {}),
1437 payload: {
1438 streamKind,
1439 delta: deltaText,
1440 },
1441 providerRefs: nativeProviderRefs(context),
1442 raw: {
1443 source: "claude.sdk.message",
1444 method: "claude/stream_event/content_block_delta",
1445 payload: message,
1446 },
1447 });
1448 return;
1449 }
1450
1451 if (event.delta.type === "input_json_delta") {
1452 const tool = context.inFlightTools.get(event.index);
1453 if (!tool || typeof event.delta.partial_json !== "string") {
1454 return;
1455 }
1456
1457 const partialInputJson = tool.partialInputJson + event.delta.partial_json;
1458 const parsedInput = tryParseJsonRecord(partialInputJson);
1459 const detail = parsedInput
1460 ? summarizeToolRequest(tool.toolName, parsedInput)
1461 : tool.detail;
1462 let nextTool: ToolInFlight = {
1463 ...tool,
1464 partialInputJson,
1465 ...(parsedInput ? { input: parsedInput } : {}),
1466 ...(detail ? { detail } : {}),
1467 };
1468
1469 const nextFingerprint =
1470 parsedInput && Object.keys(parsedInput).length > 0
1471 ? toolInputFingerprint(parsedInput)
1472 : undefined;
1473 context.inFlightTools.set(event.index, nextTool);
1474
1475 if (
1476 !parsedInput ||
1477 !nextFingerprint ||
1478 tool.lastEmittedInputFingerprint === nextFingerprint
1479 ) {
1480 return;
1481 }
1482
1483 nextTool = {
1484 ...nextTool,
1485 lastEmittedInputFingerprint: nextFingerprint,
1486 };
1487 context.inFlightTools.set(event.index, nextTool);
1488
1489 const stamp = yield* makeEventStamp();
1490 yield* offerRuntimeEvent({
1491 type: "item.updated",
1492 eventId: stamp.eventId,
1493 provider: PROVIDER,
1494 createdAt: stamp.createdAt,
1495 threadId: context.session.threadId,
1496 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1497 itemId: asRuntimeItemId(nextTool.itemId),
1498 payload: {
1499 itemType: nextTool.itemType,
1500 status: "inProgress",
1501 title: nextTool.title,
1502 ...(nextTool.detail ? { detail: nextTool.detail } : {}),
1503 data: {
1504 toolName: nextTool.toolName,
1505 input: nextTool.input,
1506 },
1507 },
1508 providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }),
1509 raw: {
1510 source: "claude.sdk.message",
1511 method: "claude/stream_event/content_block_delta/input_json_delta",
1512 payload: message,
1513 },
1514 });
1515 }
1516 return;
1517 }
1518
1519 if (event.type === "content_block_start") {
1520 const { index, content_block: block } = event;
1521 if (block.type === "text") {
1522 yield* ensureAssistantTextBlock(context, index, {
1523 fallbackText: extractContentBlockText(block),
1524 });
1525 return;
1526 }
1527 if (
1528 block.type !== "tool_use" &&
1529 block.type !== "server_tool_use" &&
1530 block.type !== "mcp_tool_use"
1531 ) {
1532 return;
1533 }
1534
1535 const toolName = block.name;
1536 const itemType = classifyToolItemType(toolName);
1537 const toolInput =
1538 typeof block.input === "object" && block.input !== null
1539 ? (block.input as Record<string, unknown>)
1540 : {};
1541 const itemId = block.id;
1542 const detail = summarizeToolRequest(toolName, toolInput);
1543 const inputFingerprint =
1544 Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;
1545
1546 const tool: ToolInFlight = {
1547 itemId,
1548 itemType,
1549 toolName,
1550 title: titleForTool(itemType),
1551 detail,
1552 input: toolInput,
1553 partialInputJson: "",
1554 ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
1555 };
1556 context.inFlightTools.set(index, tool);
1557
1558 const stamp = yield* makeEventStamp();
1559 yield* offerRuntimeEvent({
1560 type: "item.started",
1561 eventId: stamp.eventId,
1562 provider: PROVIDER,
1563 createdAt: stamp.createdAt,
1564 threadId: context.session.threadId,
1565 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1566 itemId: asRuntimeItemId(tool.itemId),
1567 payload: {
1568 itemType: tool.itemType,
1569 status: "inProgress",
1570 title: tool.title,
1571 ...(tool.detail ? { detail: tool.detail } : {}),
1572 data: {
1573 toolName: tool.toolName,
1574 input: toolInput,
1575 },
1576 },
1577 providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
1578 raw: {
1579 source: "claude.sdk.message",
1580 method: "claude/stream_event/content_block_start",
1581 payload: message,
1582 },
1583 });
1584 return;
1585 }
1586
1587 if (event.type === "content_block_stop") {
1588 const { index } = event;
1589 const assistantBlock = context.turnState?.assistantTextBlocks.get(index);
1590 if (assistantBlock) {
1591 assistantBlock.streamClosed = true;
1592 yield* completeAssistantTextBlock(context, assistantBlock, {
1593 rawMethod: "claude/stream_event/content_block_stop",
1594 rawPayload: message,
1595 });
1596 return;
1597 }
1598 const tool = context.inFlightTools.get(index);
1599 if (!tool) {
1600 return;
1601 }
1602 }
1603 });
1604
1605 const handleUserMessage = (
1606 context: ClaudeSessionContext,
1607 message: SDKMessage,
1608 ): Effect.Effect<void> =>
1609 Effect.gen(function* () {
1610 if (message.type !== "user") {
1611 return;
1612 }
1613
1614 if (context.turnState) {
1615 context.turnState.items.push(message.message);
1616 }
1617
1618 for (const toolResult of toolResultBlocksFromUserMessage(message)) {
1619 const toolEntry = Array.from(context.inFlightTools.entries()).find(
1620 ([, tool]) => tool.itemId === toolResult.toolUseId,
1621 );
1622 if (!toolEntry) {
1623 continue;
1624 }
1625
1626 const [index, tool] = toolEntry;
1627 const itemStatus = toolResult.isError ? "failed" : "completed";
1628 const toolData = {
1629 toolName: tool.toolName,
1630 input: tool.input,
1631 result: toolResult.block,
1632 };
1633
1634 const updatedStamp = yield* makeEventStamp();
1635 yield* offerRuntimeEvent({
1636 type: "item.updated",
1637 eventId: updatedStamp.eventId,
1638 provider: PROVIDER,
1639 createdAt: updatedStamp.createdAt,
1640 threadId: context.session.threadId,
1641 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1642 itemId: asRuntimeItemId(tool.itemId),
1643 payload: {
1644 itemType: tool.itemType,
1645 status: toolResult.isError ? "failed" : "inProgress",
1646 title: tool.title,
1647 ...(tool.detail ? { detail: tool.detail } : {}),
1648 data: toolData,
1649 },
1650 providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
1651 raw: {
1652 source: "claude.sdk.message",
1653 method: "claude/user",
1654 payload: message,
1655 },
1656 });
1657
1658 const streamKind = toolResultStreamKind(tool.itemType);
1659 if (streamKind && toolResult.text.length > 0 && context.turnState) {
1660 const deltaStamp = yield* makeEventStamp();
1661 yield* offerRuntimeEvent({
1662 type: "content.delta",
1663 eventId: deltaStamp.eventId,
1664 provider: PROVIDER,
1665 createdAt: deltaStamp.createdAt,
1666 threadId: context.session.threadId,
1667 turnId: context.turnState.turnId,
1668 itemId: asRuntimeItemId(tool.itemId),
1669 payload: {
1670 streamKind,
1671 delta: toolResult.text,
1672 },
1673 providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
1674 raw: {
1675 source: "claude.sdk.message",
1676 method: "claude/user",
1677 payload: message,
1678 },
1679 });
1680 }
1681
1682 const completedStamp = yield* makeEventStamp();
1683 yield* offerRuntimeEvent({
1684 type: "item.completed",
1685 eventId: completedStamp.eventId,
1686 provider: PROVIDER,
1687 createdAt: completedStamp.createdAt,
1688 threadId: context.session.threadId,
1689 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1690 itemId: asRuntimeItemId(tool.itemId),
1691 payload: {
1692 itemType: tool.itemType,
1693 status: itemStatus,
1694 title: tool.title,
1695 ...(tool.detail ? { detail: tool.detail } : {}),
1696 data: toolData,
1697 },
1698 providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
1699 raw: {
1700 source: "claude.sdk.message",
1701 method: "claude/user",
1702 payload: message,
1703 },
1704 });
1705
1706 context.inFlightTools.delete(index);
1707 }
1708 });
1709
1710 const handleAssistantMessage = (
1711 context: ClaudeSessionContext,
1712 message: SDKMessage,
1713 ): Effect.Effect<void> =>
1714 Effect.gen(function* () {
1715 if (message.type !== "assistant") {
1716 return;
1717 }
1718
1719 // Auto-start a synthetic turn for assistant messages that arrive without
1720 // an active turn (e.g., background agent/subagent responses between user prompts).
1721 if (!context.turnState) {
1722 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
1723 const startedAt = yield* nowIso;
1724 context.turnState = {
1725 turnId,
1726 startedAt,
1727 items: [],
1728 assistantTextBlocks: new Map(),
1729 assistantTextBlockOrder: [],
1730 capturedProposedPlanKeys: new Set(),
1731 nextSyntheticAssistantBlockIndex: -1,
1732 };
1733 context.session = {
1734 ...context.session,
1735 status: "running",
1736 activeTurnId: turnId,
1737 updatedAt: startedAt,
1738 };
1739 const turnStartedStamp = yield* makeEventStamp();
1740 yield* offerRuntimeEvent({
1741 type: "turn.started",
1742 eventId: turnStartedStamp.eventId,
1743 provider: PROVIDER,
1744 createdAt: turnStartedStamp.createdAt,
1745 threadId: context.session.threadId,
1746 turnId,
1747 payload: {},
1748 providerRefs: {
1749 ...nativeProviderRefs(context),
1750 providerTurnId: turnId,
1751 },
1752 raw: {
1753 source: "claude.sdk.message",
1754 method: "claude/synthetic-turn-start",
1755 payload: {},
1756 },
1757 });
1758 }
1759
1760 const content = message.message?.content;
1761 if (Array.isArray(content)) {
1762 for (const block of content) {
1763 if (!block || typeof block !== "object") {
1764 continue;
1765 }
1766 const toolUse = block as {
1767 type?: unknown;
1768 id?: unknown;
1769 name?: unknown;
1770 input?: unknown;
1771 };
1772 if (toolUse.type !== "tool_use" || toolUse.name !== "ExitPlanMode") {
1773 continue;
1774 }
1775 const planMarkdown = extractExitPlanModePlan(toolUse.input);
1776 if (!planMarkdown) {
1777 continue;
1778 }
1779 yield* emitProposedPlanCompleted(context, {
1780 planMarkdown,
1781 toolUseId: typeof toolUse.id === "string" ? toolUse.id : undefined,
1782 rawSource: "claude.sdk.message",
1783 rawMethod: "claude/assistant",
1784 rawPayload: message,
1785 });
1786 }
1787 }
1788
1789 if (context.turnState) {
1790 context.turnState.items.push(message.message);
1791 yield* backfillAssistantTextBlocksFromSnapshot(context, message);
1792 }
1793
1794 context.lastAssistantUuid = message.uuid;
1795 yield* updateResumeCursor(context);
1796 });
1797
1798 const handleResultMessage = (
1799 context: ClaudeSessionContext,
1800 message: SDKMessage,
1801 ): Effect.Effect<void> =>
1802 Effect.gen(function* () {
1803 if (message.type !== "result") {
1804 return;
1805 }
1806
1807 const status = turnStatusFromResult(message);
1808 const errorMessage = message.subtype === "success" ? undefined : message.errors[0];
1809
1810 if (status === "failed") {
1811 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
1812 }
1813
1814 yield* completeTurn(context, status, errorMessage, message);
1815 });
1816
1817 const handleSystemMessage = (
1818 context: ClaudeSessionContext,
1819 message: SDKMessage,
1820 ): Effect.Effect<void> =>
1821 Effect.gen(function* () {
1822 if (message.type !== "system") {
1823 return;
1824 }
1825
1826 const stamp = yield* makeEventStamp();
1827 const base = {
1828 eventId: stamp.eventId,
1829 provider: PROVIDER,
1830 createdAt: stamp.createdAt,
1831 threadId: context.session.threadId,
1832 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1833 providerRefs: nativeProviderRefs(context),
1834 raw: {
1835 source: "claude.sdk.message" as const,
1836 method: sdkNativeMethod(message),
1837 messageType: `${message.type}:${message.subtype}`,
1838 payload: message,
1839 },
1840 };
1841
1842 switch (message.subtype) {
1843 case "init":
1844 yield* offerRuntimeEvent({
1845 ...base,
1846 type: "session.configured",
1847 payload: {
1848 config: message as Record<string, unknown>,
1849 },
1850 });
1851 return;
1852 case "status":
1853 yield* offerRuntimeEvent({
1854 ...base,
1855 type: "session.state.changed",
1856 payload: {
1857 state: message.status === "compacting" ? "waiting" : "running",
1858 reason: `status:${message.status ?? "active"}`,
1859 detail: message,
1860 },
1861 });
1862 return;
1863 case "compact_boundary":
1864 yield* offerRuntimeEvent({
1865 ...base,
1866 type: "thread.state.changed",
1867 payload: {
1868 state: "compacted",
1869 detail: message,
1870 },
1871 });
1872 return;
1873 case "hook_started":
1874 yield* offerRuntimeEvent({
1875 ...base,
1876 type: "hook.started",
1877 payload: {
1878 hookId: message.hook_id,
1879 hookName: message.hook_name,
1880 hookEvent: message.hook_event,
1881 },
1882 });
1883 return;
1884 case "hook_progress":
1885 yield* offerRuntimeEvent({
1886 ...base,
1887 type: "hook.progress",
1888 payload: {
1889 hookId: message.hook_id,
1890 output: message.output,
1891 stdout: message.stdout,
1892 stderr: message.stderr,
1893 },
1894 });
1895 return;
1896 case "hook_response":
1897 yield* offerRuntimeEvent({
1898 ...base,
1899 type: "hook.completed",
1900 payload: {
1901 hookId: message.hook_id,
1902 outcome: message.outcome,
1903 output: message.output,
1904 stdout: message.stdout,
1905 stderr: message.stderr,
1906 ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}),
1907 },
1908 });
1909 return;
1910 case "task_started":
1911 yield* offerRuntimeEvent({
1912 ...base,
1913 type: "task.started",
1914 payload: {
1915 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1916 description: message.description,
1917 ...(message.task_type ? { taskType: message.task_type } : {}),
1918 },
1919 });
1920 return;
1921 case "task_progress":
1922 yield* offerRuntimeEvent({
1923 ...base,
1924 type: "task.progress",
1925 payload: {
1926 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1927 description: message.description,
1928 ...(message.summary ? { summary: message.summary } : {}),
1929 ...(message.usage ? { usage: message.usage } : {}),
1930 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
1931 },
1932 });
1933 return;
1934 case "task_notification":
1935 yield* offerRuntimeEvent({
1936 ...base,
1937 type: "task.completed",
1938 payload: {
1939 taskId: RuntimeTaskId.makeUnsafe(message.task_id),
1940 status: message.status,
1941 ...(message.summary ? { summary: message.summary } : {}),
1942 ...(message.usage ? { usage: message.usage } : {}),
1943 },
1944 });
1945 return;
1946 case "files_persisted":
1947 yield* offerRuntimeEvent({
1948 ...base,
1949 type: "files.persisted",
1950 payload: {
1951 files: Array.isArray(message.files)
1952 ? message.files.map((file: { filename: string; file_id: string }) => ({
1953 filename: file.filename,
1954 fileId: file.file_id,
1955 }))
1956 : [],
1957 ...(Array.isArray(message.failed)
1958 ? {
1959 failed: message.failed.map((entry: { filename: string; error: string }) => ({
1960 filename: entry.filename,
1961 error: entry.error,
1962 })),
1963 }
1964 : {}),
1965 },
1966 });
1967 return;
1968 default:
1969 yield* emitRuntimeWarning(
1970 context,
1971 `Unhandled Claude system message subtype '${message.subtype}'.`,
1972 message,
1973 );
1974 return;
1975 }
1976 });
1977
1978 const handleSdkTelemetryMessage = (
1979 context: ClaudeSessionContext,
1980 message: SDKMessage,
1981 ): Effect.Effect<void> =>
1982 Effect.gen(function* () {
1983 const stamp = yield* makeEventStamp();
1984 const base = {
1985 eventId: stamp.eventId,
1986 provider: PROVIDER,
1987 createdAt: stamp.createdAt,
1988 threadId: context.session.threadId,
1989 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1990 providerRefs: nativeProviderRefs(context),
1991 raw: {
1992 source: "claude.sdk.message" as const,
1993 method: sdkNativeMethod(message),
1994 messageType: message.type,
1995 payload: message,
1996 },
1997 };
1998
1999 if (message.type === "tool_progress") {
2000 yield* offerRuntimeEvent({
2001 ...base,
2002 type: "tool.progress",
2003 payload: {
2004 toolUseId: message.tool_use_id,
2005 toolName: message.tool_name,
2006 elapsedSeconds: message.elapsed_time_seconds,
2007 ...(message.task_id ? { summary: `task:${message.task_id}` } : {}),
2008 },
2009 });
2010 return;
2011 }
2012
2013 if (message.type === "tool_use_summary") {
2014 yield* offerRuntimeEvent({
2015 ...base,
2016 type: "tool.summary",
2017 payload: {
2018 summary: message.summary,
2019 ...(message.preceding_tool_use_ids.length > 0
2020 ? { precedingToolUseIds: message.preceding_tool_use_ids }
2021 : {}),
2022 },
2023 });
2024 return;
2025 }
2026
2027 if (message.type === "auth_status") {
2028 yield* offerRuntimeEvent({
2029 ...base,
2030 type: "auth.status",
2031 payload: {
2032 isAuthenticating: message.isAuthenticating,
2033 output: message.output,
2034 ...(message.error ? { error: message.error } : {}),
2035 },
2036 });
2037 return;
2038 }
2039
2040 if (message.type === "rate_limit_event") {
2041 yield* offerRuntimeEvent({
2042 ...base,
2043 type: "account.rate-limits.updated",
2044 payload: {
2045 rateLimits: message,
2046 },
2047 });
2048 return;
2049 }
2050 });
2051
2052 const handleSdkMessage = (
2053 context: ClaudeSessionContext,
2054 message: SDKMessage,
2055 ): Effect.Effect<void> =>
2056 Effect.gen(function* () {
2057 yield* logNativeSdkMessage(context, message);
2058 yield* ensureThreadId(context, message);
2059
2060 switch (message.type) {
2061 case "stream_event":
2062 yield* handleStreamEvent(context, message);
2063 return;
2064 case "user":
2065 yield* handleUserMessage(context, message);
2066 return;
2067 case "assistant":
2068 yield* handleAssistantMessage(context, message);
2069 return;
2070 case "result":
2071 yield* handleResultMessage(context, message);
2072 return;
2073 case "system":
2074 yield* handleSystemMessage(context, message);
2075 return;
2076 case "tool_progress":
2077 case "tool_use_summary":
2078 case "auth_status":
2079 case "rate_limit_event":
2080 yield* handleSdkTelemetryMessage(context, message);
2081 return;
2082 default:
2083 yield* emitRuntimeWarning(
2084 context,
2085 `Unhandled Claude SDK message type '${message.type}'.`,
2086 message,
2087 );
2088 return;
2089 }
2090 });
2091
2092 const runSdkStream = (context: ClaudeSessionContext): Effect.Effect<void, Error> =>
2093 Stream.fromAsyncIterable(context.query, (cause) =>
2094 toError(cause, "Claude runtime stream failed."),
2095 ).pipe(
2096 Stream.takeWhile(() => !context.stopped),
2097 Stream.runForEach((message) => handleSdkMessage(context, message)),
2098 );
2099
2100 const handleStreamExit = (
2101 context: ClaudeSessionContext,
2102 exit: Exit.Exit<void, Error>,
2103 ): Effect.Effect<void> =>
2104 Effect.gen(function* () {
2105 if (context.stopped) {
2106 return;
2107 }
2108
2109 if (Exit.isFailure(exit)) {
2110 if (isClaudeInterruptedCause(exit.cause)) {
2111 if (context.turnState) {
2112 yield* completeTurn(
2113 context,
2114 "interrupted",
2115 interruptionMessageFromClaudeCause(exit.cause),
2116 );
2117 }
2118 } else {
2119 const message = messageFromClaudeStreamCause(
2120 exit.cause,
2121 "Claude runtime stream failed.",
2122 );
2123 yield* emitRuntimeError(context, message, Cause.pretty(exit.cause));
2124 yield* completeTurn(context, "failed", message);
2125 }
2126 } else if (context.turnState) {
2127 yield* completeTurn(context, "interrupted", "Claude runtime stream ended.");
2128 }
2129
2130 yield* stopSessionInternal(context, {
2131 emitExitEvent: true,
2132 });
2133 });
2134
2135 const stopSessionInternal = (
2136 context: ClaudeSessionContext,
2137 options?: { readonly emitExitEvent?: boolean },
2138 ): Effect.Effect<void> =>
2139 Effect.gen(function* () {
2140 if (context.stopped) return;
2141
2142 context.stopped = true;
2143
2144 for (const [requestId, pending] of context.pendingApprovals) {
2145 yield* Deferred.succeed(pending.decision, "cancel");
2146 const stamp = yield* makeEventStamp();
2147 yield* offerRuntimeEvent({
2148 type: "request.resolved",
2149 eventId: stamp.eventId,
2150 provider: PROVIDER,
2151 createdAt: stamp.createdAt,
2152 threadId: context.session.threadId,
2153 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
2154 requestId: asRuntimeRequestId(requestId),
2155 payload: {
2156 requestType: pending.requestType,
2157 decision: "cancel",
2158 },
2159 providerRefs: nativeProviderRefs(context),
2160 });
2161 }
2162 context.pendingApprovals.clear();
2163
2164 if (context.turnState) {
2165 yield* completeTurn(context, "interrupted", "Session stopped.");
2166 }
2167
2168 yield* Queue.shutdown(context.promptQueue);
2169
2170 const streamFiber = context.streamFiber;
2171 context.streamFiber = undefined;
2172 if (streamFiber && streamFiber.pollUnsafe() === undefined) {
2173 yield* Fiber.interrupt(streamFiber);
2174 }
2175
2176 // @effect-diagnostics-next-line tryCatchInEffectGen:off
2177 try {
2178 context.query.close();
2179 } catch (cause) {
2180 yield* emitRuntimeError(context, "Failed to close Claude runtime query.", cause);
2181 }
2182
2183 const updatedAt = yield* nowIso;
2184 context.session = {
2185 ...context.session,
2186 status: "closed",
2187 activeTurnId: undefined,
2188 updatedAt,
2189 };
2190
2191 if (options?.emitExitEvent !== false) {
2192 const stamp = yield* makeEventStamp();
2193 yield* offerRuntimeEvent({
2194 type: "session.exited",
2195 eventId: stamp.eventId,
2196 provider: PROVIDER,
2197 createdAt: stamp.createdAt,
2198 threadId: context.session.threadId,
2199 payload: {
2200 reason: "Session stopped",
2201 exitKind: "graceful",
2202 },
2203 providerRefs: {},
2204 });
2205 }
2206
2207 sessions.delete(context.session.threadId);
2208 });
2209
2210 const requireSession = (
2211 threadId: ThreadId,
2212 ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => {
2213 const context = sessions.get(threadId);
2214 if (!context) {
2215 return Effect.fail(
2216 new ProviderAdapterSessionNotFoundError({
2217 provider: PROVIDER,
2218 threadId,
2219 }),
2220 );
2221 }
2222 if (context.stopped || context.session.status === "closed") {
2223 return Effect.fail(
2224 new ProviderAdapterSessionClosedError({
2225 provider: PROVIDER,
2226 threadId,
2227 }),
2228 );
2229 }
2230 return Effect.succeed(context);
2231 };
2232
2233 const startSession: ClaudeAdapterShape["startSession"] = (input) =>
2234 Effect.gen(function* () {
2235 if (input.provider !== undefined && input.provider !== PROVIDER) {
2236 return yield* new ProviderAdapterValidationError({
2237 provider: PROVIDER,
2238 operation: "startSession",
2239 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
2240 });
2241 }
2242
2243 const startedAt = yield* nowIso;
2244 const resumeState = readClaudeResumeState(input.resumeCursor);
2245 const threadId = input.threadId;
2246 const existingResumeSessionId = resumeState?.resume;
2247 const newSessionId =
2248 existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined;
2249 const sessionId = existingResumeSessionId ?? newSessionId;
2250
2251 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
2252 const prompt = Stream.fromQueue(promptQueue).pipe(
2253 Stream.filter((item) => item.type === "message"),
2254 Stream.map((item) => item.message),
2255 Stream.toAsyncIterable,
2256 );
2257
2258 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
2259 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
2260 const inFlightTools = new Map<number, ToolInFlight>();
2261
2262 const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined);
2263
2264 /**
2265 * Handle AskUserQuestion tool calls by emitting a `user-input.requested`
2266 * runtime event and waiting for the user to respond via `respondToUserInput`.
2267 */
2268 const handleAskUserQuestion = (
2269 context: ClaudeSessionContext,
2270 toolInput: Record<string, unknown>,
2271 callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string },
2272 ) =>
2273 Effect.gen(function* () {
2274 const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4);
2275
2276 // Parse questions from the SDK's AskUserQuestion input.
2277 const rawQuestions = Array.isArray(toolInput.questions) ? toolInput.questions : [];
2278 const questions: Array<UserInputQuestion> = rawQuestions.map(
2279 (q: Record<string, unknown>, idx: number) => ({
2280 id: typeof q.header === "string" ? q.header : `q-${idx}`,
2281 header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`,
2282 question: typeof q.question === "string" ? q.question : "",
2283 options: Array.isArray(q.options)
2284 ? q.options.map((opt: Record<string, unknown>) => ({
2285 label: typeof opt.label === "string" ? opt.label : "",
2286 description: typeof opt.description === "string" ? opt.description : "",
2287 }))
2288 : [],
2289 multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : false,
2290 }),
2291 );
2292
2293 const answersDeferred = yield* Deferred.make<ProviderUserInputAnswers>();
2294 let aborted = false;
2295 const pendingInput: PendingUserInput = {
2296 questions,
2297 answers: answersDeferred,
2298 };
2299
2300 // Emit user-input.requested so the UI can present the questions.
2301 const requestedStamp = yield* makeEventStamp();
2302 yield* offerRuntimeEvent({
2303 type: "user-input.requested",
2304 eventId: requestedStamp.eventId,
2305 provider: PROVIDER,
2306 createdAt: requestedStamp.createdAt,
2307 threadId: context.session.threadId,
2308 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
2309 requestId: asRuntimeRequestId(requestId),
2310 payload: { questions },
2311 providerRefs: nativeProviderRefs(context, {
2312 providerItemId: callbackOptions.toolUseID,
2313 }),
2314 raw: {
2315 source: "claude.sdk.permission",
2316 method: "canUseTool/AskUserQuestion",
2317 payload: { toolName: "AskUserQuestion", input: toolInput },
2318 },
2319 });
2320
2321 pendingUserInputs.set(requestId, pendingInput);
2322
2323 // Handle abort (e.g. turn interrupted while waiting for user input).
2324 const onAbort = () => {
2325 if (!pendingUserInputs.has(requestId)) {
2326 return;
2327 }
2328 aborted = true;
2329 pendingUserInputs.delete(requestId);
2330 Effect.runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers));
2331 };
2332 callbackOptions.signal.addEventListener("abort", onAbort, { once: true });
2333
2334 // Block until the user provides answers.
2335 const answers = yield* Deferred.await(answersDeferred);
2336 pendingUserInputs.delete(requestId);
2337
2338 // Emit user-input.resolved so the UI knows the interaction completed.
2339 const resolvedStamp = yield* makeEventStamp();
2340 yield* offerRuntimeEvent({
2341 type: "user-input.resolved",
2342 eventId: resolvedStamp.eventId,
2343 provider: PROVIDER,
2344 createdAt: resolvedStamp.createdAt,
2345 threadId: context.session.threadId,
2346 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
2347 requestId: asRuntimeRequestId(requestId),
2348 payload: { answers },
2349 providerRefs: nativeProviderRefs(context, {
2350 providerItemId: callbackOptions.toolUseID,
2351 }),
2352 raw: {
2353 source: "claude.sdk.permission",
2354 method: "canUseTool/AskUserQuestion/resolved",
2355 payload: { answers },
2356 },
2357 });
2358
2359 if (aborted) {
2360 return {
2361 behavior: "deny",
2362 message: "User cancelled tool execution.",
2363 } satisfies PermissionResult;
2364 }
2365
2366 // Return the answers to the SDK in the expected format:
2367 // { questions: [...], answers: { questionText: selectedLabel } }
2368 return {
2369 behavior: "allow",
2370 updatedInput: {
2371 questions: toolInput.questions,
2372 answers,
2373 },
2374 } satisfies PermissionResult;
2375 });
2376
2377 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
2378 Effect.runPromise(
2379 Effect.gen(function* () {
2380 const context = yield* Ref.get(contextRef);
2381 if (!context) {
2382 return {
2383 behavior: "deny",
2384 message: "Claude session context is unavailable.",
2385 } satisfies PermissionResult;
2386 }
2387
2388 // Handle AskUserQuestion: surface clarifying questions to the
2389 // user via the user-input runtime event channel, regardless of
2390 // runtime mode (plan mode relies on this heavily).
2391 if (toolName === "AskUserQuestion") {
2392 return yield* handleAskUserQuestion(context, toolInput, callbackOptions);
2393 }
2394
2395 if (toolName === "ExitPlanMode") {
2396 const planMarkdown = extractExitPlanModePlan(toolInput);
2397 if (planMarkdown) {
2398 yield* emitProposedPlanCompleted(context, {
2399 planMarkdown,
2400 toolUseId: callbackOptions.toolUseID,
2401 rawSource: "claude.sdk.permission",
2402 rawMethod: "canUseTool/ExitPlanMode",
2403 rawPayload: {
2404 toolName,
2405 input: toolInput,
2406 },
2407 });
2408 }
2409
2410 return {
2411 behavior: "deny",
2412 message:
2413 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
2414 } satisfies PermissionResult;
2415 }
2416
2417 const runtimeMode = input.runtimeMode ?? "full-access";
2418 if (runtimeMode === "full-access") {
2419 return {
2420 behavior: "allow",
2421 updatedInput: toolInput,
2422 } satisfies PermissionResult;
2423 }
2424
2425 const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4);
2426 const requestType = classifyRequestType(toolName);
2427 const detail = summarizeToolRequest(toolName, toolInput);
2428 const decisionDeferred = yield* Deferred.make<ProviderApprovalDecision>();
2429 const pendingApproval: PendingApproval = {
2430 requestType,
2431 detail,
2432 decision: decisionDeferred,
2433 ...(callbackOptions.suggestions
2434 ? { suggestions: callbackOptions.suggestions }
2435 : {}),
2436 };
2437
2438 const requestedStamp = yield* makeEventStamp();
2439 yield* offerRuntimeEvent({
2440 type: "request.opened",
2441 eventId: requestedStamp.eventId,
2442 provider: PROVIDER,
2443 createdAt: requestedStamp.createdAt,
2444 threadId: context.session.threadId,
2445 ...(context.turnState
2446 ? { turnId: asCanonicalTurnId(context.turnState.turnId) }
2447 : {}),
2448 requestId: asRuntimeRequestId(requestId),
2449 payload: {
2450 requestType,
2451 detail,
2452 args: {
2453 toolName,
2454 input: toolInput,
2455 ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}),
2456 },
2457 },
2458 providerRefs: nativeProviderRefs(context, {
2459 providerItemId: callbackOptions.toolUseID,
2460 }),
2461 raw: {
2462 source: "claude.sdk.permission",
2463 method: "canUseTool/request",
2464 payload: {
2465 toolName,
2466 input: toolInput,
2467 },
2468 },
2469 });
2470
2471 pendingApprovals.set(requestId, pendingApproval);
2472
2473 const onAbort = () => {
2474 if (!pendingApprovals.has(requestId)) {
2475 return;
2476 }
2477 pendingApprovals.delete(requestId);
2478 Effect.runFork(Deferred.succeed(decisionDeferred, "cancel"));
2479 };
2480
2481 callbackOptions.signal.addEventListener("abort", onAbort, {
2482 once: true,
2483 });
2484
2485 const decision = yield* Deferred.await(decisionDeferred);
2486 pendingApprovals.delete(requestId);
2487
2488 const resolvedStamp = yield* makeEventStamp();
2489 yield* offerRuntimeEvent({
2490 type: "request.resolved",
2491 eventId: resolvedStamp.eventId,
2492 provider: PROVIDER,
2493 createdAt: resolvedStamp.createdAt,
2494 threadId: context.session.threadId,
2495 ...(context.turnState
2496 ? { turnId: asCanonicalTurnId(context.turnState.turnId) }
2497 : {}),
2498 requestId: asRuntimeRequestId(requestId),
2499 payload: {
2500 requestType,
2501 decision,
2502 },
2503 providerRefs: nativeProviderRefs(context, {
2504 providerItemId: callbackOptions.toolUseID,
2505 }),
2506 raw: {
2507 source: "claude.sdk.permission",
2508 method: "canUseTool/decision",
2509 payload: {
2510 decision,
2511 },
2512 },
2513 });
2514
2515 if (decision === "accept" || decision === "acceptForSession") {
2516 return {
2517 behavior: "allow",
2518 updatedInput: toolInput,
2519 ...(decision === "acceptForSession" && pendingApproval.suggestions
2520 ? { updatedPermissions: [...pendingApproval.suggestions] }
2521 : {}),
2522 } satisfies PermissionResult;
2523 }
2524
2525 return {
2526 behavior: "deny",
2527 message:
2528 decision === "cancel"
2529 ? "User cancelled tool execution."
2530 : "User declined tool execution.",
2531 } satisfies PermissionResult;
2532 }),
2533 );
2534
2535 const providerOptions = input.providerOptions?.claudeAgent;
2536 const requestedEffort = resolveReasoningEffortForProvider(
2537 "claudeAgent",
2538 input.modelOptions?.claudeAgent?.effort ?? null,
2539 );
2540 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
2541 const effort =
2542 requestedEffort && supportedEffortOptions.includes(requestedEffort)
2543 ? requestedEffort
2544 : null;
2545 const fastMode =
2546 input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model);
2547 const thinking =
2548 typeof input.modelOptions?.claudeAgent?.thinking === "boolean" &&
2549 supportsClaudeThinkingToggle(input.model)
2550 ? input.modelOptions.claudeAgent.thinking
2551 : undefined;
2552 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
2553 const permissionMode =
2554 toPermissionMode(providerOptions?.permissionMode) ??
2555 (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined);
2556 const settings = {
2557 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
2558 ...(fastMode ? { fastMode: true } : {}),
2559 };
2560
2561 const queryOptions: ClaudeQueryOptions = {
2562 ...(input.cwd ? { cwd: input.cwd } : {}),
2563 ...(input.model ? { model: input.model } : {}),
2564 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
2565 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
2566 ...(permissionMode ? { permissionMode } : {}),
2567 ...(permissionMode === "bypassPermissions"
2568 ? { allowDangerouslySkipPermissions: true }
2569 : {}),
2570 ...(providerOptions?.maxThinkingTokens !== undefined
2571 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
2572 : {}),
2573 ...(Object.keys(settings).length > 0 ? { settings } : {}),
2574 ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}),
2575 ...(newSessionId ? { sessionId: newSessionId } : {}),
2576 includePartialMessages: true,
2577 canUseTool,
2578 env: process.env,
2579 ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
2580 };
2581
2582 const queryRuntime = yield* Effect.try({
2583 try: () =>
2584 createQuery({
2585 prompt,
2586 options: queryOptions,
2587 }),
2588 catch: (cause) =>
2589 new ProviderAdapterProcessError({
2590 provider: PROVIDER,
2591 threadId,
2592 detail: toMessage(cause, "Failed to start Claude runtime session."),
2593 cause,
2594 }),
2595 });
2596
2597 const session: ProviderSession = {
2598 threadId,
2599 provider: PROVIDER,
2600 status: "ready",
2601 runtimeMode: input.runtimeMode,
2602 ...(input.cwd ? { cwd: input.cwd } : {}),
2603 ...(input.model ? { model: input.model } : {}),
2604 ...(threadId ? { threadId } : {}),
2605 resumeCursor: {
2606 ...(threadId ? { threadId } : {}),
2607 ...(sessionId ? { resume: sessionId } : {}),
2608 ...(resumeState?.resumeSessionAt
2609 ? { resumeSessionAt: resumeState.resumeSessionAt }
2610 : {}),
2611 turnCount: resumeState?.turnCount ?? 0,
2612 },
2613 createdAt: startedAt,
2614 updatedAt: startedAt,
2615 };
2616
2617 const context: ClaudeSessionContext = {
2618 session,
2619 promptQueue,
2620 query: queryRuntime,
2621 streamFiber: undefined,
2622 startedAt,
2623 basePermissionMode: permissionMode,
2624 resumeSessionId: sessionId,
2625 pendingApprovals,
2626 pendingUserInputs,
2627 turns: [],
2628 inFlightTools,
2629 turnState: undefined,
2630 lastAssistantUuid: resumeState?.resumeSessionAt,
2631 lastThreadStartedId: undefined,
2632 stopped: false,
2633 };
2634 yield* Ref.set(contextRef, context);
2635 sessions.set(threadId, context);
2636
2637 const sessionStartedStamp = yield* makeEventStamp();
2638 yield* offerRuntimeEvent({
2639 type: "session.started",
2640 eventId: sessionStartedStamp.eventId,
2641 provider: PROVIDER,
2642 createdAt: sessionStartedStamp.createdAt,
2643 threadId,
2644 payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {},
2645 providerRefs: {},
2646 });
2647
2648 const configuredStamp = yield* makeEventStamp();
2649 yield* offerRuntimeEvent({
2650 type: "session.configured",
2651 eventId: configuredStamp.eventId,
2652 provider: PROVIDER,
2653 createdAt: configuredStamp.createdAt,
2654 threadId,
2655 payload: {
2656 config: {
2657 ...(input.model ? { model: input.model } : {}),
2658 ...(input.cwd ? { cwd: input.cwd } : {}),
2659 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
2660 ...(permissionMode ? { permissionMode } : {}),
2661 ...(providerOptions?.maxThinkingTokens !== undefined
2662 ? { maxThinkingTokens: providerOptions.maxThinkingTokens }
2663 : {}),
2664 ...(fastMode ? { fastMode: true } : {}),
2665 },
2666 },
2667 providerRefs: {},
2668 });
2669
2670 const readyStamp = yield* makeEventStamp();
2671 yield* offerRuntimeEvent({
2672 type: "session.state.changed",
2673 eventId: readyStamp.eventId,
2674 provider: PROVIDER,
2675 createdAt: readyStamp.createdAt,
2676 threadId,
2677 payload: {
2678 state: "ready",
2679 },
2680 providerRefs: {},
2681 });
2682
2683 const streamFiber = Effect.runFork(runSdkStream(context));
2684 context.streamFiber = streamFiber;
2685 streamFiber.addObserver((exit) => {
2686 if (context.stopped) {
2687 return;
2688 }
2689 if (context.streamFiber === streamFiber) {
2690 context.streamFiber = undefined;
2691 }
2692 Effect.runFork(handleStreamExit(context, exit));
2693 });
2694
2695 return {
2696 ...session,
2697 };
2698 });
2699
2700 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>
2701 Effect.gen(function* () {
2702 const context = yield* requireSession(input.threadId);
2703
2704 if (context.turnState) {
2705 // Auto-close a stale synthetic turn (from background agent responses
2706 // between user prompts) to prevent blocking the user's next turn.
2707 yield* completeTurn(context, "completed");
2708 }
2709
2710 if (input.model) {
2711 yield* Effect.tryPromise({
2712 try: () => context.query.setModel(input.model),
2713 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
2714 });
2715 }
2716
2717 // Apply interaction mode by switching the SDK's permission mode.
2718 // "plan" maps directly to the SDK's "plan" permission mode;
2719 // "default" restores the session's original permission mode.
2720 // When interactionMode is absent we leave the current mode unchanged.
2721 if (input.interactionMode === "plan") {
2722 yield* Effect.tryPromise({
2723 try: () => context.query.setPermissionMode("plan"),
2724 catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
2725 });
2726 } else if (input.interactionMode === "default") {
2727 yield* Effect.tryPromise({
2728 try: () =>
2729 context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"),
2730 catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
2731 });
2732 }
2733
2734 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
2735 const turnState: ClaudeTurnState = {
2736 turnId,
2737 startedAt: yield* nowIso,
2738 items: [],
2739 assistantTextBlocks: new Map(),
2740 assistantTextBlockOrder: [],
2741 capturedProposedPlanKeys: new Set(),
2742 nextSyntheticAssistantBlockIndex: -1,
2743 };
2744
2745 const updatedAt = yield* nowIso;
2746 context.turnState = turnState;
2747 context.session = {
2748 ...context.session,
2749 status: "running",
2750 activeTurnId: turnId,
2751 updatedAt,
2752 };
2753
2754 const turnStartedStamp = yield* makeEventStamp();
2755 yield* offerRuntimeEvent({
2756 type: "turn.started",
2757 eventId: turnStartedStamp.eventId,
2758 provider: PROVIDER,
2759 createdAt: turnStartedStamp.createdAt,
2760 threadId: context.session.threadId,
2761 turnId,
2762 payload: input.model ? { model: input.model } : {},
2763 providerRefs: {},
2764 });
2765
2766 const message = yield* buildUserMessageEffect(input, {
2767 fileSystem,
2768 stateDir: serverConfig.stateDir,
2769 });
2770
2771 yield* Queue.offer(context.promptQueue, {
2772 type: "message",
2773 message,
2774 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
2775
2776 return {
2777 threadId: context.session.threadId,
2778 turnId,
2779 ...(context.session.resumeCursor !== undefined
2780 ? { resumeCursor: context.session.resumeCursor }
2781 : {}),
2782 };
2783 });
2784
2785 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = (threadId, _turnId) =>
2786 Effect.gen(function* () {
2787 const context = yield* requireSession(threadId);
2788 yield* Effect.tryPromise({
2789 try: () => context.query.interrupt(),
2790 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
2791 });
2792 });
2793
2794 const readThread: ClaudeAdapterShape["readThread"] = (threadId) =>
2795 Effect.gen(function* () {
2796 const context = yield* requireSession(threadId);
2797 return yield* snapshotThread(context);
2798 });
2799
2800 const rollbackThread: ClaudeAdapterShape["rollbackThread"] = (threadId, numTurns) =>
2801 Effect.gen(function* () {
2802 const context = yield* requireSession(threadId);
2803 const nextLength = Math.max(0, context.turns.length - numTurns);
2804 context.turns.splice(nextLength);
2805 yield* updateResumeCursor(context);
2806 return yield* snapshotThread(context);
2807 });
2808
2809 const respondToRequest: ClaudeAdapterShape["respondToRequest"] = (
2810 threadId,
2811 requestId,
2812 decision,
2813 ) =>
2814 Effect.gen(function* () {
2815 const context = yield* requireSession(threadId);
2816 const pending = context.pendingApprovals.get(requestId);
2817 if (!pending) {
2818 return yield* new ProviderAdapterRequestError({
2819 provider: PROVIDER,
2820 method: "item/requestApproval/decision",
2821 detail: `Unknown pending approval request: ${requestId}`,
2822 });
2823 }
2824
2825 context.pendingApprovals.delete(requestId);
2826 yield* Deferred.succeed(pending.decision, decision);
2827 });
2828
2829 const respondToUserInput: ClaudeAdapterShape["respondToUserInput"] = (
2830 threadId,
2831 requestId,
2832 answers,
2833 ) =>
2834 Effect.gen(function* () {
2835 const context = yield* requireSession(threadId);
2836 const pending = context.pendingUserInputs.get(requestId);
2837 if (!pending) {
2838 return yield* new ProviderAdapterRequestError({
2839 provider: PROVIDER,
2840 method: "item/tool/respondToUserInput",
2841 detail: `Unknown pending user-input request: ${requestId}`,
2842 });
2843 }
2844
2845 context.pendingUserInputs.delete(requestId);
2846 yield* Deferred.succeed(pending.answers, answers);
2847 });
2848
2849 const stopSession: ClaudeAdapterShape["stopSession"] = (threadId) =>
2850 Effect.gen(function* () {
2851 const context = yield* requireSession(threadId);
2852 yield* stopSessionInternal(context, {
2853 emitExitEvent: true,
2854 });
2855 });
2856
2857 const listSessions: ClaudeAdapterShape["listSessions"] = () =>
2858 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
2859
2860 const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) =>
2861 Effect.sync(() => {
2862 const context = sessions.get(threadId);
2863 return context !== undefined && !context.stopped;
2864 });
2865
2866 const stopAll: ClaudeAdapterShape["stopAll"] = () =>
2867 Effect.forEach(
2868 sessions,
2869 ([, context]) =>
2870 stopSessionInternal(context, {
2871 emitExitEvent: true,
2872 }),
2873 { discard: true },
2874 );
2875
2876 yield* Effect.addFinalizer(() =>
2877 Effect.forEach(
2878 sessions,
2879 ([, context]) =>
2880 stopSessionInternal(context, {
2881 emitExitEvent: false,
2882 }),
2883 { discard: true },
2884 ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))),
2885 );
2886
2887 return {
2888 provider: PROVIDER,
2889 capabilities: {
2890 sessionModelSwitch: "in-session",
2891 },
2892 startSession,
2893 sendTurn,
2894 interruptTurn,
2895 readThread,
2896 rollbackThread,
2897 respondToRequest,
2898 respondToUserInput,
2899 stopSession,
2900 listSessions,
2901 hasSession,
2902 stopAll,
2903 streamEvents: Stream.fromQueue(runtimeEventQueue),
2904 } satisfies ClaudeAdapterShape;
2905 });
2906}
2907
2908export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter());
2909
2910export function makeClaudeAdapterLive(options?: ClaudeAdapterLiveOptions) {
2911 return Layer.effect(ClaudeAdapter, makeClaudeAdapter(options));
2912}
apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts created+149
......@@ -0,0 +1,149 @@
1import { EventEmitter } from "node:events";
2import { PassThrough } from "node:stream";
3import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
4import { afterEach, describe, expect, it } from "vitest";
5
6async function* emptyPrompt(): AsyncGenerator<never, void, void> {}
7
8class FakeClaudeCodeProcess implements SpawnedProcess {
9 readonly stdin = new PassThrough();
10 readonly stdout = new PassThrough();
11 killed = false;
12 exitCode: number | null = null;
13
14 private readonly events = new EventEmitter();
15 private bufferedInput = "";
16
17 constructor(
18 private readonly onMessage: (
19 message: Record<string, unknown>,
20 process: FakeClaudeCodeProcess,
21 ) => void,
22 ) {
23 this.stdin.setEncoding("utf8");
24 this.stdin.on("data", (chunk: string) => {
25 this.bufferedInput += chunk;
26 this.drainInput();
27 });
28 }
29
30 emitJson(message: unknown): void {
31 this.stdout.write(`${JSON.stringify(message)}\n`);
32 }
33
34 kill(_signal: NodeJS.Signals): boolean {
35 this.killed = true;
36 this.exitCode = 0;
37 this.stdout.end();
38 this.events.emit("exit", 0, null);
39 return true;
40 }
41
42 on(
43 event: "exit" | "error",
44 listener:
45 | ((code: number | null, signal: NodeJS.Signals | null) => void)
46 | ((error: Error) => void),
47 ): void {
48 this.events.on(event, listener);
49 }
50
51 once(
52 event: "exit" | "error",
53 listener:
54 | ((code: number | null, signal: NodeJS.Signals | null) => void)
55 | ((error: Error) => void),
56 ): void {
57 this.events.once(event, listener);
58 }
59
60 off(
61 event: "exit" | "error",
62 listener:
63 | ((code: number | null, signal: NodeJS.Signals | null) => void)
64 | ((error: Error) => void),
65 ): void {
66 this.events.off(event, listener);
67 }
68
69 private drainInput(): void {
70 while (true) {
71 const newlineIndex = this.bufferedInput.indexOf("\n");
72 if (newlineIndex === -1) {
73 return;
74 }
75 const line = this.bufferedInput.slice(0, newlineIndex).trim();
76 this.bufferedInput = this.bufferedInput.slice(newlineIndex + 1);
77 if (line.length === 0) {
78 continue;
79 }
80 this.onMessage(JSON.parse(line) as Record<string, unknown>, this);
81 }
82 }
83}
84
85describe("Claude SDK fast mode probe", () => {
86 let activeQuery: ReturnType<typeof query> | null = null;
87
88 afterEach(() => {
89 activeQuery?.close();
90 activeQuery = null;
91 });
92
93 it("passes fast mode through the SDK settings flag", async () => {
94 let spawnOptions: SpawnOptions | undefined;
95
96 activeQuery = query({
97 prompt: emptyPrompt(),
98 options: {
99 persistSession: false,
100 settings: {
101 fastMode: true,
102 },
103 spawnClaudeCodeProcess: (options): SpawnedProcess => {
104 spawnOptions = options;
105 return new FakeClaudeCodeProcess((message, process) => {
106 if (
107 message.type !== "control_request" ||
108 typeof message.request_id !== "string" ||
109 !message.request ||
110 typeof message.request !== "object" ||
111 (message.request as { subtype?: unknown }).subtype !== "initialize"
112 ) {
113 return;
114 }
115
116 process.emitJson({
117 type: "control_response",
118 response: {
119 subtype: "success",
120 request_id: message.request_id,
121 response: {
122 commands: [],
123 agents: [],
124 output_style: "default",
125 available_output_styles: ["default"],
126 models: [],
127 account: {
128 subscriptionType: "max",
129 },
130 fast_mode_state: "on",
131 },
132 },
133 });
134 });
135 },
136 },
137 });
138
139 const initialization = await activeQuery.initializationResult();
140 expect(initialization.fast_mode_state).toBe("on");
141
142 expect(spawnOptions).toBeDefined();
143 const settingsFlagIndex = spawnOptions?.args.indexOf("--settings") ?? -1;
144 expect(settingsFlagIndex).toBeGreaterThan(-1);
145 expect(JSON.parse(spawnOptions?.args[settingsFlagIndex + 1] ?? "")).toEqual({
146 fastMode: true,
147 });
148 });
149});
apps/server/src/provider/Layers/CodexAdapter.test.ts+63
......@@ -22,6 +22,7 @@ import {
2222 type CodexAppServerSendTurnInput,
2323} from "../../codexAppServerManager.ts";
2424import { ServerConfig } from "../../config.ts";
25import { ProviderAdapterValidationError } from "../Errors.ts";
2526import { CodexAdapter } from "../Services/CodexAdapter.ts";
2627import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2728import { makeCodexAdapterLive } from "./CodexAdapter.ts";
......@@ -156,6 +157,29 @@ const validationLayer = it.layer(
156157);
157158
158159validationLayer("CodexAdapterLive validation", (it) => {
160 it.effect("returns validation error for non-codex provider on startSession", () =>
161 Effect.gen(function* () {
162 const adapter = yield* CodexAdapter;
163 const result = yield* adapter
164 .startSession({
165 provider: "claudeAgent",
166 threadId: asThreadId("thread-1"),
167 runtimeMode: "full-access",
168 })
169 .pipe(Effect.result);
170
171 assert.equal(result._tag, "Failure");
172 assert.deepStrictEqual(
173 result.failure,
174 new ProviderAdapterValidationError({
175 provider: "codex",
176 operation: "startSession",
177 issue: "Expected provider 'codex' but received 'claudeAgent'.",
178 }),
179 );
180 assert.equal(validationManager.startSessionImpl.mock.calls.length, 0);
181 }),
182 );
159183 it.effect("maps codex model options before starting a session", () =>
160184 Effect.gen(function* () {
161185 validationManager.startSessionImpl.mockClear();
......@@ -776,6 +800,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
776800 }
777801 }),
778802 );
803
804 it.effect("prefers manager-assigned turn ids for Codex task events", () =>
805 Effect.gen(function* () {
806 const adapter = yield* CodexAdapter;
807 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
808
809 lifecycleManager.emit("event", {
810 id: asEventId("evt-codex-task-started-parent-turn"),
811 kind: "notification",
812 provider: "codex",
813 threadId: asThreadId("thread-1"),
814 turnId: asTurnId("turn-parent"),
815 createdAt: new Date().toISOString(),
816 method: "codex/event/task_started",
817 payload: {
818 id: "turn-child",
819 msg: {
820 type: "task_started",
821 turn_id: "turn-child",
822 collaboration_mode_kind: "default",
823 },
824 conversationId: "child-provider-thread",
825 },
826 } satisfies ProviderEvent);
827
828 const firstEvent = yield* Fiber.join(firstEventFiber);
829 assert.equal(firstEvent._tag, "Some");
830 if (firstEvent._tag !== "Some") {
831 return;
832 }
833 assert.equal(firstEvent.value.type, "task.started");
834 if (firstEvent.value.type !== "task.started") {
835 return;
836 }
837 assert.equal(firstEvent.value.turnId, "turn-parent");
838 assert.equal(firstEvent.value.providerRefs?.providerTurnId, "turn-parent");
839 assert.equal(firstEvent.value.payload.taskId, "turn-child");
840 }),
841 );
779842});
780843
781844afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+14-6
......@@ -109,6 +109,14 @@ function asNumber(value: unknown): number | undefined {
109109 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
110110}
111111
112function toTurnId(value: string | undefined): TurnId | undefined {
113 return value?.trim() ? TurnId.makeUnsafe(value) : undefined;
114}
115
116function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
117 return value?.trim() ? ProviderItemId.makeUnsafe(value) : undefined;
118}
119
112120function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" {
113121 switch (value) {
114122 case "completed":
......@@ -415,27 +423,27 @@ function codexEventBase(
415423): Omit<ProviderRuntimeEvent, "type" | "payload"> {
416424 const payload = asObject(event.payload);
417425 const msg = codexEventMessage(payload);
418 const turnId = asString(msg?.turn_id) ?? asString(msg?.turnId);
419 const itemId = asString(msg?.item_id) ?? asString(msg?.itemId);
426 const turnId = event.turnId ?? toTurnId(asString(msg?.turn_id) ?? asString(msg?.turnId));
427 const itemId = event.itemId ?? toProviderItemId(asString(msg?.item_id) ?? asString(msg?.itemId));
420428 const requestId = asString(msg?.request_id) ?? asString(msg?.requestId);
421429 const base = runtimeEventBase(event, canonicalThreadId);
422430 const providerRefs = base.providerRefs
423431 ? {
424432 ...base.providerRefs,
425433 ...(turnId ? { providerTurnId: turnId } : {}),
426 ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}),
434 ...(itemId ? { providerItemId: itemId } : {}),
427435 ...(requestId ? { providerRequestId: requestId } : {}),
428436 }
429437 : {
430438 ...(turnId ? { providerTurnId: turnId } : {}),
431 ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}),
439 ...(itemId ? { providerItemId: itemId } : {}),
432440 ...(requestId ? { providerRequestId: requestId } : {}),
433441 };
434442
435443 return {
436444 ...base,
437 ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}),
438 ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}),
445 ...(turnId ? { turnId } : {}),
446 ...(itemId ? { itemId: asRuntimeItemId(itemId) } : {}),
439447 ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}),
440448 ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}),
441449 };
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+28-2
......@@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils";
44
55import { Effect, Layer, Stream } from "effect";
66
7import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
78import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
89import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
910import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
......@@ -27,9 +28,32 @@ const fakeCodexAdapter: CodexAdapterShape = {
2728 streamEvents: Stream.empty,
2829};
2930
31const fakeClaudeAdapter: ClaudeAdapterShape = {
32 provider: "claudeAgent",
33 capabilities: { sessionModelSwitch: "in-session" },
34 startSession: vi.fn(),
35 sendTurn: vi.fn(),
36 interruptTurn: vi.fn(),
37 respondToRequest: vi.fn(),
38 respondToUserInput: vi.fn(),
39 stopSession: vi.fn(),
40 listSessions: vi.fn(),
41 hasSession: vi.fn(),
42 readThread: vi.fn(),
43 rollbackThread: vi.fn(),
44 stopAll: vi.fn(),
45 streamEvents: Stream.empty,
46};
47
3048const layer = it.layer(
3149 Layer.mergeAll(
32 Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)),
50 Layer.provide(
51 ProviderAdapterRegistryLive,
52 Layer.mergeAll(
53 Layer.succeed(CodexAdapter, fakeCodexAdapter),
54 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
55 ),
56 ),
3357 NodeServices.layer,
3458 ),
3559);
......@@ -39,10 +63,12 @@ layer("ProviderAdapterRegistryLive", (it) => {
3963 Effect.gen(function* () {
4064 const registry = yield* ProviderAdapterRegistry;
4165 const codex = yield* registry.getByProvider("codex");
66 const claude = yield* registry.getByProvider("claudeAgent");
4267 assert.equal(codex, fakeCodexAdapter);
68 assert.equal(claude, fakeClaudeAdapter);
4369
4470 const providers = yield* registry.listProviders();
45 assert.deepEqual(providers, ["codex"]);
71 assert.deepEqual(providers, ["codex", "claudeAgent"]);
4672 }),
4773 );
4874
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+6-2
......@@ -1,7 +1,7 @@
11/**
22 * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer.
33 *
4 * Binds provider kinds (codex/cursor/...) to concrete adapter services.
4 * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services.
55 * This layer only performs adapter lookup; it does not route session-scoped
66 * calls or own provider lifecycle workflows.
77 *
......@@ -15,6 +15,7 @@ import {
1515 ProviderAdapterRegistry,
1616 type ProviderAdapterRegistryShape,
1717} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1819import { CodexAdapter } from "../Services/CodexAdapter.ts";
1920
2021export interface ProviderAdapterRegistryLiveOptions {
......@@ -23,7 +24,10 @@ export interface ProviderAdapterRegistryLiveOptions {
2324
2425const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>
2526 Effect.gen(function* () {
26 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];
27 const adapters =
28 options?.adapters !== undefined
29 ? options.adapters
30 : [yield* CodexAdapter, yield* ClaudeAdapter];
2731 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
2832
2933 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderHealth.test.ts+173
......@@ -5,9 +5,11 @@ import * as PlatformError from "effect/PlatformError";
55import { ChildProcessSpawner } from "effect/unstable/process";
66
77import {
8 checkClaudeProviderStatus,
89 checkCodexProviderStatus,
910 hasCustomModelProvider,
1011 parseAuthStatusFromOutput,
12 parseClaudeAuthStatusFromOutput,
1113 readCodexConfigModelProvider,
1214} from "./ProviderHealth";
1315
......@@ -464,4 +466,175 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
464466 }),
465467 );
466468 });
469
470 // ── checkClaudeProviderStatus tests ──────────────────────────
471
472 describe("checkClaudeProviderStatus", () => {
473 it.effect("returns ready when claude is installed and authenticated", () =>
474 Effect.gen(function* () {
475 const status = yield* checkClaudeProviderStatus;
476 assert.strictEqual(status.provider, "claudeAgent");
477 assert.strictEqual(status.status, "ready");
478 assert.strictEqual(status.available, true);
479 assert.strictEqual(status.authStatus, "authenticated");
480 }).pipe(
481 Effect.provide(
482 mockSpawnerLayer((args) => {
483 const joined = args.join(" ");
484 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
485 if (joined === "auth status")
486 return {
487 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
488 stderr: "",
489 code: 0,
490 };
491 throw new Error(`Unexpected args: ${joined}`);
492 }),
493 ),
494 ),
495 );
496
497 it.effect("returns unavailable when claude is missing", () =>
498 Effect.gen(function* () {
499 const status = yield* checkClaudeProviderStatus;
500 assert.strictEqual(status.provider, "claudeAgent");
501 assert.strictEqual(status.status, "error");
502 assert.strictEqual(status.available, false);
503 assert.strictEqual(status.authStatus, "unknown");
504 assert.strictEqual(
505 status.message,
506 "Claude Agent CLI (`claude`) is not installed or not on PATH.",
507 );
508 }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))),
509 );
510
511 it.effect("returns error when version check fails with non-zero exit code", () =>
512 Effect.gen(function* () {
513 const status = yield* checkClaudeProviderStatus;
514 assert.strictEqual(status.provider, "claudeAgent");
515 assert.strictEqual(status.status, "error");
516 assert.strictEqual(status.available, false);
517 }).pipe(
518 Effect.provide(
519 mockSpawnerLayer((args) => {
520 const joined = args.join(" ");
521 if (joined === "--version")
522 return { stdout: "", stderr: "Something went wrong", code: 1 };
523 throw new Error(`Unexpected args: ${joined}`);
524 }),
525 ),
526 ),
527 );
528
529 it.effect("returns unauthenticated when auth status reports not logged in", () =>
530 Effect.gen(function* () {
531 const status = yield* checkClaudeProviderStatus;
532 assert.strictEqual(status.provider, "claudeAgent");
533 assert.strictEqual(status.status, "error");
534 assert.strictEqual(status.available, true);
535 assert.strictEqual(status.authStatus, "unauthenticated");
536 assert.strictEqual(
537 status.message,
538 "Claude is not authenticated. Run `claude auth login` and try again.",
539 );
540 }).pipe(
541 Effect.provide(
542 mockSpawnerLayer((args) => {
543 const joined = args.join(" ");
544 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
545 if (joined === "auth status")
546 return {
547 stdout: '{"loggedIn":false}\n',
548 stderr: "",
549 code: 1,
550 };
551 throw new Error(`Unexpected args: ${joined}`);
552 }),
553 ),
554 ),
555 );
556
557 it.effect("returns unauthenticated when output includes 'not logged in'", () =>
558 Effect.gen(function* () {
559 const status = yield* checkClaudeProviderStatus;
560 assert.strictEqual(status.provider, "claudeAgent");
561 assert.strictEqual(status.status, "error");
562 assert.strictEqual(status.available, true);
563 assert.strictEqual(status.authStatus, "unauthenticated");
564 }).pipe(
565 Effect.provide(
566 mockSpawnerLayer((args) => {
567 const joined = args.join(" ");
568 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
569 if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 };
570 throw new Error(`Unexpected args: ${joined}`);
571 }),
572 ),
573 ),
574 );
575
576 it.effect("returns warning when auth status command is unsupported", () =>
577 Effect.gen(function* () {
578 const status = yield* checkClaudeProviderStatus;
579 assert.strictEqual(status.provider, "claudeAgent");
580 assert.strictEqual(status.status, "warning");
581 assert.strictEqual(status.available, true);
582 assert.strictEqual(status.authStatus, "unknown");
583 assert.strictEqual(
584 status.message,
585 "Claude Agent authentication status command is unavailable in this version of Claude.",
586 );
587 }).pipe(
588 Effect.provide(
589 mockSpawnerLayer((args) => {
590 const joined = args.join(" ");
591 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
592 if (joined === "auth status")
593 return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 };
594 throw new Error(`Unexpected args: ${joined}`);
595 }),
596 ),
597 ),
598 );
599 });
600
601 // ── parseClaudeAuthStatusFromOutput pure tests ────────────────────
602
603 describe("parseClaudeAuthStatusFromOutput", () => {
604 it("exit code 0 with no auth markers is ready", () => {
605 const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
606 assert.strictEqual(parsed.status, "ready");
607 assert.strictEqual(parsed.authStatus, "authenticated");
608 });
609
610 it("JSON with loggedIn=true is authenticated", () => {
611 const parsed = parseClaudeAuthStatusFromOutput({
612 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
613 stderr: "",
614 code: 0,
615 });
616 assert.strictEqual(parsed.status, "ready");
617 assert.strictEqual(parsed.authStatus, "authenticated");
618 });
619
620 it("JSON with loggedIn=false is unauthenticated", () => {
621 const parsed = parseClaudeAuthStatusFromOutput({
622 stdout: '{"loggedIn":false}\n',
623 stderr: "",
624 code: 0,
625 });
626 assert.strictEqual(parsed.status, "error");
627 assert.strictEqual(parsed.authStatus, "unauthenticated");
628 });
629
630 it("JSON without auth marker is warning", () => {
631 const parsed = parseClaudeAuthStatusFromOutput({
632 stdout: '{"ok":true}\n',
633 stderr: "",
634 code: 0,
635 });
636 assert.strictEqual(parsed.status, "warning");
637 assert.strictEqual(parsed.authStatus, "unknown");
638 });
639 });
467640});
apps/server/src/provider/Layers/ProviderHealth.ts+207-11
......@@ -26,6 +26,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe
2626
2727const DEFAULT_TIMEOUT_MS = 4_000;
2828const CODEX_PROVIDER = "codex" as const;
29const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const;
2930
3031// ── Pure helpers ────────────────────────────────────────────────────
3132
......@@ -44,12 +45,7 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined {
4445function isCommandMissingCause(error: unknown): boolean {
4546 if (!(error instanceof Error)) return false;
4647 const lower = error.message.toLowerCase();
47 return (
48 lower.includes("command not found: codex") ||
49 lower.includes("spawn codex enoent") ||
50 lower.includes("enoent") ||
51 lower.includes("notfound")
52 );
48 return lower.includes("enoent") || lower.includes("notfound");
5349}
5450
5551function detailFromResult(
......@@ -264,6 +260,27 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
264260 return { stdout, stderr, code: exitCode } satisfies CommandResult;
265261 }).pipe(Effect.scoped);
266262
263const runClaudeCommand = (args: ReadonlyArray<string>) =>
264 Effect.gen(function* () {
265 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
266 const command = ChildProcess.make("claude", [...args], {
267 shell: process.platform === "win32",
268 });
269
270 const child = yield* spawner.spawn(command);
271
272 const [stdout, stderr, exitCode] = yield* Effect.all(
273 [
274 collectStreamAsString(child.stdout),
275 collectStreamAsString(child.stderr),
276 child.exitCode.pipe(Effect.map(Number)),
277 ],
278 { concurrency: "unbounded" },
279 );
280
281 return { stdout, stderr, code: exitCode } satisfies CommandResult;
282 }).pipe(Effect.scoped);
283
267284// ── Health check ────────────────────────────────────────────────────
268285
269286export const checkCodexProviderStatus: Effect.Effect<
......@@ -390,18 +407,197 @@ export const checkCodexProviderStatus: Effect.Effect<
390407 } satisfies ServerProviderStatus;
391408});
392409
410// ── Claude Agent health check ───────────────────────────────────────
411
412export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
413 readonly status: ServerProviderStatusState;
414 readonly authStatus: ServerProviderAuthStatus;
415 readonly message?: string;
416} {
417 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
418
419 if (
420 lowerOutput.includes("unknown command") ||
421 lowerOutput.includes("unrecognized command") ||
422 lowerOutput.includes("unexpected argument")
423 ) {
424 return {
425 status: "warning",
426 authStatus: "unknown",
427 message:
428 "Claude Agent authentication status command is unavailable in this version of Claude.",
429 };
430 }
431
432 if (
433 lowerOutput.includes("not logged in") ||
434 lowerOutput.includes("login required") ||
435 lowerOutput.includes("authentication required") ||
436 lowerOutput.includes("run `claude login`") ||
437 lowerOutput.includes("run claude login")
438 ) {
439 return {
440 status: "error",
441 authStatus: "unauthenticated",
442 message: "Claude is not authenticated. Run `claude auth login` and try again.",
443 };
444 }
445
446 // `claude auth status` returns JSON with a `loggedIn` boolean.
447 const parsedAuth = (() => {
448 const trimmed = result.stdout.trim();
449 if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
450 return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
451 }
452 try {
453 return {
454 attemptedJsonParse: true as const,
455 auth: extractAuthBoolean(JSON.parse(trimmed)),
456 };
457 } catch {
458 return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
459 }
460 })();
461
462 if (parsedAuth.auth === true) {
463 return { status: "ready", authStatus: "authenticated" };
464 }
465 if (parsedAuth.auth === false) {
466 return {
467 status: "error",
468 authStatus: "unauthenticated",
469 message: "Claude is not authenticated. Run `claude auth login` and try again.",
470 };
471 }
472 if (parsedAuth.attemptedJsonParse) {
473 return {
474 status: "warning",
475 authStatus: "unknown",
476 message:
477 "Could not verify Claude authentication status from JSON output (missing auth marker).",
478 };
479 }
480 if (result.code === 0) {
481 return { status: "ready", authStatus: "authenticated" };
482 }
483
484 const detail = detailFromResult(result);
485 return {
486 status: "warning",
487 authStatus: "unknown",
488 message: detail
489 ? `Could not verify Claude authentication status. ${detail}`
490 : "Could not verify Claude authentication status.",
491 };
492}
493
494export const checkClaudeProviderStatus: Effect.Effect<
495 ServerProviderStatus,
496 never,
497 ChildProcessSpawner.ChildProcessSpawner
498> = Effect.gen(function* () {
499 const checkedAt = new Date().toISOString();
500
501 // Probe 1: `claude --version` — is the CLI reachable?
502 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(
503 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
504 Effect.result,
505 );
506
507 if (Result.isFailure(versionProbe)) {
508 const error = versionProbe.failure;
509 return {
510 provider: CLAUDE_AGENT_PROVIDER,
511 status: "error" as const,
512 available: false,
513 authStatus: "unknown" as const,
514 checkedAt,
515 message: isCommandMissingCause(error)
516 ? "Claude Agent CLI (`claude`) is not installed or not on PATH."
517 : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
518 };
519 }
520
521 if (Option.isNone(versionProbe.success)) {
522 return {
523 provider: CLAUDE_AGENT_PROVIDER,
524 status: "error" as const,
525 available: false,
526 authStatus: "unknown" as const,
527 checkedAt,
528 message: "Claude Agent CLI is installed but failed to run. Timed out while running command.",
529 };
530 }
531
532 const version = versionProbe.success.value;
533 if (version.code !== 0) {
534 const detail = detailFromResult(version);
535 return {
536 provider: CLAUDE_AGENT_PROVIDER,
537 status: "error" as const,
538 available: false,
539 authStatus: "unknown" as const,
540 checkedAt,
541 message: detail
542 ? `Claude Agent CLI is installed but failed to run. ${detail}`
543 : "Claude Agent CLI is installed but failed to run.",
544 };
545 }
546
547 // Probe 2: `claude auth status` — is the user authenticated?
548 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
549 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
550 Effect.result,
551 );
552
553 if (Result.isFailure(authProbe)) {
554 const error = authProbe.failure;
555 return {
556 provider: CLAUDE_AGENT_PROVIDER,
557 status: "warning" as const,
558 available: true,
559 authStatus: "unknown" as const,
560 checkedAt,
561 message:
562 error instanceof Error
563 ? `Could not verify Claude authentication status: ${error.message}.`
564 : "Could not verify Claude authentication status.",
565 };
566 }
567
568 if (Option.isNone(authProbe.success)) {
569 return {
570 provider: CLAUDE_AGENT_PROVIDER,
571 status: "warning" as const,
572 available: true,
573 authStatus: "unknown" as const,
574 checkedAt,
575 message: "Could not verify Claude authentication status. Timed out while running command.",
576 };
577 }
578
579 const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value);
580 return {
581 provider: CLAUDE_AGENT_PROVIDER,
582 status: parsed.status,
583 available: true,
584 authStatus: parsed.authStatus,
585 checkedAt,
586 ...(parsed.message ? { message: parsed.message } : {}),
587 } satisfies ServerProviderStatus;
588});
589
393590// ── Layer ───────────────────────────────────────────────────────────
394591
395592export const ProviderHealthLive = Layer.effect(
396593 ProviderHealth,
397594 Effect.gen(function* () {
398 const codexStatusFiber = yield* checkCodexProviderStatus.pipe(
399 Effect.map(Array.of),
400 Effect.forkScoped,
401 );
595 const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], {
596 concurrency: "unbounded",
597 }).pipe(Effect.forkScoped);
402598
403599 return {
404 getStatuses: Fiber.join(codexStatusFiber),
600 getStatuses: Fiber.join(statusesFiber),
405601 } satisfies ProviderHealthShape;
406602 }),
407603);
apps/server/src/provider/Layers/ProviderService.test.ts+205-11
......@@ -52,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
5252type LegacyProviderRuntimeEvent = {
5353 readonly type: string;
5454 readonly eventId: EventId;
55 readonly provider: "codex";
55 readonly provider: ProviderKind;
5656 readonly createdAt: string;
5757 readonly threadId: ThreadId;
5858 readonly turnId?: string | undefined;
......@@ -74,7 +74,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
7474 status: "ready",
7575 runtimeMode: input.runtimeMode,
7676 threadId: input.threadId,
77 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` },
77 resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` },
7878 cwd: input.cwd ?? process.cwd(),
7979 createdAt: now,
8080 updatedAt: now,
......@@ -195,9 +195,21 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
195195 Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent));
196196 };
197197
198 const updateSession = (
199 threadId: ThreadId,
200 update: (session: ProviderSession) => ProviderSession,
201 ): void => {
202 const existing = sessions.get(threadId);
203 if (!existing) {
204 return;
205 }
206 sessions.set(threadId, update(existing));
207 };
208
198209 return {
199210 adapter,
200211 emit,
212 updateSession,
201213 startSession,
202214 sendTurn,
203215 interruptTurn,
......@@ -217,12 +229,15 @@ const sleep = (ms: number) =>
217229
218230function makeProviderServiceLayer() {
219231 const codex = makeFakeCodexAdapter();
232 const claude = makeFakeCodexAdapter("claudeAgent");
220233 const registry: typeof ProviderAdapterRegistry.Service = {
221234 getByProvider: (provider) =>
222235 provider === "codex"
223236 ? Effect.succeed(codex.adapter)
224 : Effect.fail(new ProviderUnsupportedError({ provider })),
225 listProviders: () => Effect.succeed(["codex"]),
237 : provider === "claudeAgent"
238 ? Effect.succeed(claude.adapter)
239 : Effect.fail(new ProviderUnsupportedError({ provider })),
240 listProviders: () => Effect.succeed(["codex", "claudeAgent"]),
226241 };
227242
228243 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
......@@ -247,6 +262,7 @@ function makeProviderServiceLayer() {
247262
248263 return {
249264 codex,
265 claude,
250266 layer,
251267 };
252268}
......@@ -344,16 +360,29 @@ it.effect(
344360 Layer.provide(firstDirectoryLayer),
345361 Layer.provide(AnalyticsService.layerTest),
346362 );
363 const updatedResumeCursor = {
364 threadId: asThreadId("thread-1"),
365 resume: "resume-session-1",
366 resumeSessionAt: "assistant-message-1",
367 turnCount: 1,
368 };
347369
348370 const startedSession = yield* Effect.gen(function* () {
349371 const provider = yield* ProviderService;
350372 const threadId = asThreadId("thread-1");
351 return yield* provider.startSession(threadId, {
373 const session = yield* provider.startSession(threadId, {
352374 provider: "codex",
353375 cwd: "/tmp/project",
354376 runtimeMode: "full-access",
355377 threadId,
356378 });
379 firstCodex.updateSession(threadId, (existing) => ({
380 ...existing,
381 status: "ready",
382 resumeCursor: updatedResumeCursor,
383 updatedAt: new Date(Date.now() + 1_000).toISOString(),
384 }));
385 return session;
357386 }).pipe(Effect.provide(firstProviderLayer));
358387
359388 const persistedAfterStopAll = yield* Effect.gen(function* () {
......@@ -363,7 +392,7 @@ it.effect(
363392 assert.equal(Option.isSome(persistedAfterStopAll), true);
364393 if (Option.isSome(persistedAfterStopAll)) {
365394 assert.equal(persistedAfterStopAll.value.status, "stopped");
366 assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor);
395 assert.deepEqual(persistedAfterStopAll.value.resumeCursor, updatedResumeCursor);
367396 }
368397
369398 const secondCodex = makeFakeCodexAdapter();
......@@ -406,7 +435,7 @@ it.effect(
406435 };
407436 assert.equal(startPayload.provider, "codex");
408437 assert.equal(startPayload.cwd, "/tmp/project");
409 assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor);
438 assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor);
410439 assert.equal(startPayload.threadId, startedSession.threadId);
411440 }
412441 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
......@@ -533,6 +562,29 @@ routing.layer("ProviderServiceLive routing", (it) => {
533562 }),
534563 );
535564
565 it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () =>
566 Effect.gen(function* () {
567 const provider = yield* ProviderService;
568
569 const session = yield* provider.startSession(asThreadId("thread-claude"), {
570 provider: "claudeAgent",
571 threadId: asThreadId("thread-claude"),
572 cwd: "/tmp/project-claude",
573 runtimeMode: "full-access",
574 });
575
576 assert.equal(session.provider, "claudeAgent");
577 assert.equal(routing.claude.startSession.mock.calls.length, 1);
578 const startInput = routing.claude.startSession.mock.calls[0]?.[0];
579 assert.equal(typeof startInput === "object" && startInput !== null, true);
580 if (startInput && typeof startInput === "object") {
581 const startPayload = startInput as { provider?: string; cwd?: string };
582 assert.equal(startPayload.provider, "claudeAgent");
583 assert.equal(startPayload.cwd, "/tmp/project-claude");
584 }
585 }),
586 );
587
536588 it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>
537589 Effect.gen(function* () {
538590 const provider = yield* ProviderService;
......@@ -573,6 +625,57 @@ routing.layer("ProviderServiceLive routing", (it) => {
573625 }),
574626 );
575627
628 it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () =>
629 Effect.gen(function* () {
630 const provider = yield* ProviderService;
631
632 const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), {
633 provider: "claudeAgent",
634 threadId: asThreadId("thread-claude-send-turn"),
635 cwd: "/tmp/project-claude-send-turn",
636 modelOptions: {
637 claudeAgent: {
638 effort: "max",
639 },
640 },
641 runtimeMode: "full-access",
642 });
643
644 yield* routing.claude.stopAll();
645 routing.claude.startSession.mockClear();
646 routing.claude.sendTurn.mockClear();
647
648 yield* provider.sendTurn({
649 threadId: initial.threadId,
650 input: "resume with claude",
651 attachments: [],
652 });
653
654 assert.equal(routing.claude.startSession.mock.calls.length, 1);
655 const resumedStartInput = routing.claude.startSession.mock.calls[0]?.[0];
656 assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true);
657 if (resumedStartInput && typeof resumedStartInput === "object") {
658 const startPayload = resumedStartInput as {
659 provider?: string;
660 cwd?: string;
661 modelOptions?: unknown;
662 resumeCursor?: unknown;
663 threadId?: string;
664 };
665 assert.equal(startPayload.provider, "claudeAgent");
666 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
667 assert.deepEqual(startPayload.modelOptions, {
668 claudeAgent: {
669 effort: "max",
670 },
671 });
672 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
673 assert.equal(startPayload.threadId, initial.threadId);
674 }
675 assert.equal(routing.claude.sendTurn.mock.calls.length, 1);
676 }),
677 );
678
576679 it.effect("lists no sessions after adapter runtime clears", () =>
577680 Effect.gen(function* () {
578681 const provider = yield* ProviderService;
......@@ -589,6 +692,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
589692 });
590693
591694 yield* routing.codex.stopAll();
695 yield* routing.claude.stopAll();
592696
593697 const remaining = yield* provider.listSessions();
594698 assert.equal(remaining.length, 0);
......@@ -637,6 +741,96 @@ routing.layer("ProviderServiceLive routing", (it) => {
637741 }
638742 }),
639743 );
744
745 it.effect("reuses persisted resume cursor when startSession is called after a restart", () =>
746 Effect.gen(function* () {
747 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-"));
748 const dbPath = path.join(tempDir, "orchestration.sqlite");
749 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
750 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
751 Layer.provide(persistenceLayer),
752 );
753
754 const firstClaude = makeFakeCodexAdapter("claudeAgent");
755 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
756 getByProvider: (provider) =>
757 provider === "claudeAgent"
758 ? Effect.succeed(firstClaude.adapter)
759 : Effect.fail(new ProviderUnsupportedError({ provider })),
760 listProviders: () => Effect.succeed(["claudeAgent"]),
761 };
762 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
763 Layer.provide(runtimeRepositoryLayer),
764 );
765 const firstProviderLayer = makeProviderServiceLive().pipe(
766 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
767 Layer.provide(firstDirectoryLayer),
768 Layer.provide(AnalyticsService.layerTest),
769 );
770
771 const initial = yield* Effect.gen(function* () {
772 const provider = yield* ProviderService;
773 return yield* provider.startSession(asThreadId("thread-claude-start"), {
774 provider: "claudeAgent",
775 threadId: asThreadId("thread-claude-start"),
776 cwd: "/tmp/project-claude-start",
777 runtimeMode: "full-access",
778 });
779 }).pipe(Effect.provide(firstProviderLayer));
780
781 yield* Effect.gen(function* () {
782 const provider = yield* ProviderService;
783 yield* provider.listSessions();
784 }).pipe(Effect.provide(firstProviderLayer));
785
786 const secondClaude = makeFakeCodexAdapter("claudeAgent");
787 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
788 getByProvider: (provider) =>
789 provider === "claudeAgent"
790 ? Effect.succeed(secondClaude.adapter)
791 : Effect.fail(new ProviderUnsupportedError({ provider })),
792 listProviders: () => Effect.succeed(["claudeAgent"]),
793 };
794 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
795 Layer.provide(runtimeRepositoryLayer),
796 );
797 const secondProviderLayer = makeProviderServiceLive().pipe(
798 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
799 Layer.provide(secondDirectoryLayer),
800 Layer.provide(AnalyticsService.layerTest),
801 );
802
803 secondClaude.startSession.mockClear();
804
805 yield* Effect.gen(function* () {
806 const provider = yield* ProviderService;
807 yield* provider.startSession(initial.threadId, {
808 provider: "claudeAgent",
809 threadId: initial.threadId,
810 cwd: "/tmp/project-claude-start",
811 runtimeMode: "full-access",
812 });
813 }).pipe(Effect.provide(secondProviderLayer));
814
815 assert.equal(secondClaude.startSession.mock.calls.length, 1);
816 const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0];
817 assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true);
818 if (resumedStartInput && typeof resumedStartInput === "object") {
819 const startPayload = resumedStartInput as {
820 provider?: string;
821 cwd?: string;
822 resumeCursor?: unknown;
823 threadId?: string;
824 };
825 assert.equal(startPayload.provider, "claudeAgent");
826 assert.equal(startPayload.cwd, "/tmp/project-claude-start");
827 assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor);
828 assert.equal(startPayload.threadId, initial.threadId);
829 }
830
831 fs.rmSync(tempDir, { recursive: true, force: true });
832 }).pipe(Effect.provide(NodeServices.layer)),
833 );
640834});
641835
642836const fanout = makeProviderServiceLayer();
......@@ -654,7 +848,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
654848 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>
655849 Ref.update(eventsRef, (current) => [...current, event]),
656850 ).pipe(Effect.forkChild);
657 yield* sleep(20);
851 yield* sleep(50);
658852
659853 const completedEvent: LegacyProviderRuntimeEvent = {
660854 type: "turn.completed",
......@@ -667,7 +861,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
667861 };
668862
669863 fanout.codex.emit(completedEvent);
670 yield* sleep(20);
864 yield* sleep(50);
671865
672866 const events = yield* Ref.get(eventsRef);
673867 yield* Fiber.interrupt(consumer);
......@@ -693,7 +887,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
693887 Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])),
694888 Effect.forkChild,
695889 );
696 yield* sleep(20);
890 yield* sleep(50);
697891
698892 fanout.codex.emit({
699893 type: "tool.started",
......@@ -757,7 +951,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
757951 Stream.runForEach(() => Effect.fail("listener crash")),
758952 Effect.forkChild,
759953 );
760 yield* sleep(20);
954 yield* sleep(50);
761955
762956 const events: ReadonlyArray<LegacyProviderRuntimeEvent> = [
763957 {
apps/server/src/provider/Layers/ProviderService.ts+52-4
......@@ -88,17 +88,38 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st
8888
8989function toRuntimePayloadFromSession(
9090 session: ProviderSession,
91 extra?: { readonly providerOptions?: unknown },
91 extra?: {
92 readonly modelOptions?: unknown;
93 readonly providerOptions?: unknown;
94 readonly lastRuntimeEvent?: string;
95 readonly lastRuntimeEventAt?: string;
96 },
9297): Record<string, unknown> {
9398 return {
9499 cwd: session.cwd ?? null,
95100 model: session.model ?? null,
96101 activeTurnId: session.activeTurnId ?? null,
97102 lastError: session.lastError ?? null,
103 ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),
98104 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106 ...(extra?.lastRuntimeEventAt !== undefined
107 ? { lastRuntimeEventAt: extra.lastRuntimeEventAt }
108 : {}),
99109 };
100110}
101111
112function readPersistedModelOptions(
113 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
114): Record<string, unknown> | undefined {
115 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
116 return undefined;
117 }
118 const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined;
119 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
120 return raw as Record<string, unknown>;
121}
122
102123function readPersistedProviderOptions(
103124 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
104125): Record<string, unknown> | undefined {
......@@ -150,7 +171,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
150171 const upsertSessionBinding = (
151172 session: ProviderSession,
152173 threadId: ThreadId,
153 extra?: { readonly providerOptions?: unknown },
174 extra?: {
175 readonly modelOptions?: unknown;
176 readonly providerOptions?: unknown;
177 readonly lastRuntimeEvent?: string;
178 readonly lastRuntimeEventAt?: string;
179 },
154180 ) =>
155181 directory.upsert({
156182 threadId,
......@@ -165,7 +191,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
165191 const adapters = yield* Effect.forEach(providers, (provider) =>
166192 registry.getByProvider(provider),
167193 );
168
169194 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
170195 publishRuntimeEvent(event);
171196
......@@ -213,12 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
213238 }
214239
215240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241 const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload);
216242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
217243
218244 const resumed = yield* adapter.startSession({
219245 threadId: input.binding.threadId,
220246 provider: input.binding.provider,
221247 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248 ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}),
222249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
223250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
224251 runtimeMode: input.binding.runtimeMode ?? "full-access",
......@@ -281,8 +308,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
281308 threadId,
282309 provider: parsed.provider ?? "codex",
283310 };
311 const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId));
312 const effectiveResumeCursor =
313 input.resumeCursor ??
314 (persistedBinding?.provider === input.provider
315 ? persistedBinding.resumeCursor
316 : undefined);
284317 const adapter = yield* registry.getByProvider(input.provider);
285 const session = yield* adapter.startSession(input);
318 const session = yield* adapter.startSession({
319 ...input,
320 ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}),
321 });
286322
287323 if (session.provider !== adapter.provider) {
288324 return yield* toValidationError(
......@@ -292,6 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
292328 }
293329
294330 yield* upsertSessionBinding(session, threadId, {
331 modelOptions: input.modelOptions,
295332 providerOptions: input.providerOptions,
296333 });
297334 yield* analytics.record("provider.session.started", {
......@@ -498,6 +535,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
498535 const runStopAll = () =>
499536 Effect.gen(function* () {
500537 const threadIds = yield* directory.listThreadIds();
538 const activeSessions = yield* Effect.forEach(adapters, (adapter) =>
539 adapter.listSessions(),
540 ).pipe(
541 Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)),
542 );
543 yield* Effect.forEach(activeSessions, (session) =>
544 upsertSessionBinding(session, session.threadId, {
545 lastRuntimeEvent: "provider.stopAll",
546 lastRuntimeEventAt: new Date().toISOString(),
547 }),
548 ).pipe(Effect.asVoid);
501549 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
502550 yield* Effect.forEach(threadIds, (threadId) =>
503551 directory.getProvider(threadId).pipe(
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-2
......@@ -141,8 +141,8 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
141141
142142 yield* runtimeRepository.upsert({
143143 threadId,
144 providerName: "cursor",
145 adapterKey: "cursor",
144 providerName: "claudeAgent",
145 adapterKey: "claudeAgent",
146146 runtimeMode: "full-access",
147147 status: "running",
148148 lastSeenAt: new Date().toISOString(),
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1
......@@ -22,7 +22,7 @@ function decodeProviderKind(
2222 providerName: string,
2323 operation: string,
2424): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
25 if (providerName === "codex") {
25 if (providerName === "codex" || providerName === "claudeAgent") {
2626 return Effect.succeed(providerName);
2727 }
2828 return Effect.fail(
apps/server/src/provider/Services/ClaudeAdapter.ts created+30
......@@ -0,0 +1,30 @@
1/**
2 * ClaudeAdapter - Claude Agent implementation of the generic provider adapter contract.
3 *
4 * This service owns Claude runtime/session semantics and emits canonical
5 * provider runtime events. It does not perform cross-provider routing, shared
6 * event fan-out, or checkpoint orchestration.
7 *
8 * Uses Effect `ServiceMap.Service` for dependency injection and returns the
9 * shared provider-adapter error channel with `provider: "claudeAgent"` context.
10 *
11 * @module ClaudeAdapter
12 */
13import { ServiceMap } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
17
18/**
19 * ClaudeAdapterShape - Service API for the Claude Agent provider adapter.
20 */
21export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "claudeAgent";
23}
24
25/**
26 * ClaudeAdapter - Service tag for Claude Agent provider adapter operations.
27 */
28export class ClaudeAdapter extends ServiceMap.Service<ClaudeAdapter, ClaudeAdapterShape>()(
29 "t3/provider/Services/ClaudeAdapter",
30) {}
apps/server/src/provider/Services/ProviderHealth.ts+3-3
......@@ -1,8 +1,8 @@
11/**
22 * ProviderHealth - Provider readiness snapshot service.
33 *
4 * Owns startup-time provider health checks (install/auth reachability) and
5 * exposes the cached results to transport layers.
4 * Owns provider health checks (install/auth reachability) and exposes the
5 * latest results to transport layers.
66 *
77 * @module ProviderHealth
88 */
......@@ -12,7 +12,7 @@ import type { Effect } from "effect";
1212
1313export interface ProviderHealthShape {
1414 /**
15 * Read provider health statuses computed at server startup.
15 * Read the latest provider health statuses.
1616 */
1717 readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
1818}
apps/server/src/serverLayers.ts+5
......@@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers
1919import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
2020import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
2121import { ProviderUnsupportedError } from "./provider/Errors";
22import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
2223import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
2324import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
2425import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
......@@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer<
5859 const codexAdapterLayer = makeCodexAdapterLive(
5960 nativeEventLogger ? { nativeEventLogger } : undefined,
6061 );
62 const claudeAdapterLayer = makeClaudeAdapterLive(
63 nativeEventLogger ? { nativeEventLogger } : undefined,
64 );
6165 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
6266 Layer.provide(codexAdapterLayer),
67 Layer.provide(claudeAdapterLayer),
6368 Layer.provideMerge(providerSessionDirectoryLayer),
6469 );
6570 return makeProviderServiceLive(
apps/web/src/appSettings.test.ts+48
......@@ -1,6 +1,8 @@
1import { Schema } from "effect";
12import { describe, expect, it } from "vitest";
23
34import {
5 AppSettingsSchema,
46 DEFAULT_TIMESTAMP_FORMAT,
57 getAppModelOptions,
68 normalizeCustomModelSlugs,
......@@ -20,6 +22,13 @@ describe("normalizeCustomModelSlugs", () => {
2022 ]),
2123 ).toEqual(["custom/internal-model"]);
2224 });
25
26 it("normalizes provider-specific aliases for claude", () => {
27 expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]);
28 expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([
29 "claude/custom-sonnet",
30 ]);
31 });
2332});
2433
2534describe("getAppModelOptions", () => {
......@@ -46,6 +55,13 @@ describe("getAppModelOptions", () => {
4655 isCustom: true,
4756 });
4857 });
58 it("keeps a saved custom provider model available as an exact slug option", () => {
59 const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus");
60
61 expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe(
62 true,
63 );
64 });
4965});
5066
5167describe("resolveAppModelSelection", () => {
......@@ -65,3 +81,35 @@ describe("timestamp format defaults", () => {
6581 expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
6682 });
6783});
84
85describe("provider-specific custom models", () => {
86 it("includes provider-specific custom slugs in non-codex model lists", () => {
87 const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]);
88
89 expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true);
90 });
91});
92
93describe("AppSettingsSchema", () => {
94 it("fills decoding defaults for persisted settings that predate newer keys", () => {
95 const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
96
97 expect(
98 decode(
99 JSON.stringify({
100 codexBinaryPath: "/usr/local/bin/codex",
101 confirmThreadDelete: false,
102 }),
103 ),
104 ).toMatchObject({
105 codexBinaryPath: "/usr/local/bin/codex",
106 codexHomePath: "",
107 defaultThreadEnvMode: "local",
108 confirmThreadDelete: false,
109 enableAssistantStreaming: false,
110 timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
111 customCodexModels: [],
112 customClaudeModels: [],
113 });
114 });
115});
apps/web/src/appSettings.ts+36-26
......@@ -3,37 +3,43 @@ import { Option, Schema } from "effect";
33import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
44import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55import { useLocalStorage } from "./hooks/useLocalStorage";
6import { EnvMode } from "./components/BranchToolbar.logic";
67
78const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
89const MAX_CUSTOM_MODEL_COUNT = 32;
910export const MAX_CUSTOM_MODEL_LENGTH = 256;
10export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
11export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
11
12export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
13export type TimestampFormat = typeof TimestampFormat.Type;
1214export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
15
1316const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
1417 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
18 claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)),
1519};
1620
17const AppSettingsSchema = Schema.Struct({
18 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
19 Schema.withConstructorDefault(() => Option.some("")),
20 ),
21 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
22 Schema.withConstructorDefault(() => Option.some("")),
23 ),
24 defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe(
25 Schema.withConstructorDefault(() => Option.some("local")),
26 ),
27 confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
28 enableAssistantStreaming: Schema.Boolean.pipe(
29 Schema.withConstructorDefault(() => Option.some(false)),
30 ),
31 timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe(
32 Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
33 ),
34 customCodexModels: Schema.Array(Schema.String).pipe(
35 Schema.withConstructorDefault(() => Option.some([])),
36 ),
21const withDefaults =
22 <
23 S extends Schema.Top & Schema.WithoutConstructorDefault,
24 D extends S["~type.make.in"] & S["Encoded"],
25 >(
26 fallback: () => D,
27 ) =>
28 (schema: S) =>
29 schema.pipe(
30 Schema.withConstructorDefault(() => Option.some(fallback())),
31 Schema.withDecodingDefault(() => fallback()),
32 );
33
34export const AppSettingsSchema = Schema.Struct({
35 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
36 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
37 defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
38 confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
39 enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
40 timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)),
41 customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
42 customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
3743 textGenerationModel: Schema.optional(TrimmedNonEmptyString),
3844});
3945export type AppSettings = typeof AppSettingsSchema.Type;
......@@ -74,6 +80,13 @@ export function normalizeCustomModelSlugs(
7480 return normalizedModels;
7581}
7682
83function normalizeAppSettings(settings: AppSettings): AppSettings {
84 return {
85 ...settings,
86 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
87 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
88 };
89}
7790export function getAppModelOptions(
7891 provider: ProviderKind,
7992 customModels: readonly string[],
......@@ -152,10 +165,7 @@ export function useAppSettings() {
152165
153166 const updateSettings = useCallback(
154167 (patch: Partial<AppSettings>) => {
155 setSettings((prev) => ({
156 ...prev,
157 ...patch,
158 }));
168 setSettings((prev) => normalizeAppSettings({ ...prev, ...patch }));
159169 },
160170 [setSettings],
161171 );
apps/web/src/components/BranchToolbar.logic.ts+3-1
......@@ -1,6 +1,8 @@
11import type { GitBranch } from "@t3tools/contracts";
2import { Schema } from "effect";
23
3export type EnvMode = "local" | "worktree";
4export const EnvMode = Schema.Literals(["local", "worktree"]);
5export type EnvMode = typeof EnvMode.Type;
46
57export function resolveEffectiveEnvMode(input: {
68 activeWorktreePath: string | null;
apps/web/src/components/ChatView.logic.ts+2
......@@ -123,9 +123,11 @@ export function cloneComposerImageForRetry(
123123
124124export function getCustomModelOptionsByProvider(settings: {
125125 customCodexModels: readonly string[];
126 customClaudeModels: readonly string[];
126127}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
127128 return {
128129 codex: getAppModelOptions("codex", settings.customCodexModels),
130 claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels),
129131 };
130132}
131133
apps/web/src/components/ChatView.tsx+522-412
......@@ -1,32 +1,38 @@
11import {
22 type ApprovalRequestId,
33 DEFAULT_MODEL_BY_PROVIDER,
4 type EditorId,
5 type KeybindingCommand,
6 type CodexReasoningEffort,
4 type ClaudeCodeEffort,
75 type MessageId,
8 type ProjectId,
9 type ProjectEntry,
106 type ProjectScript,
117 type ModelSlug,
8 type ProviderKind,
9 type ProjectEntry,
10 type ProjectId,
11 type ProviderApprovalDecision,
1212 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1313 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1414 type ResolvedKeybindingsConfig,
15 type ProviderApprovalDecision,
1615 type ServerProviderStatus,
17 type ProviderKind,
1816 type ThreadId,
1917 type TurnId,
18 type EditorId,
19 type KeybindingCommand,
2020 OrchestrationThreadActivity,
21 RuntimeMode,
2221 ProviderInteractionMode,
22 RuntimeMode,
2323} from "@t3tools/contracts";
2424import {
25 applyClaudePromptEffortPrefix,
2526 getDefaultModel,
2627 getDefaultReasoningEffort,
2728 getReasoningEffortOptions,
29 isClaudeUltrathinkPrompt,
30 normalizeClaudeModelOptions,
31 normalizeCodexModelOptions,
2832 normalizeModelSlug,
33 resolveReasoningEffortForProvider,
2934 resolveModelSlugForProvider,
35 supportsClaudeUltrathinkKeyword,
3036} from "@t3tools/shared/model";
3137import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3238import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -53,6 +59,7 @@ import {
5359 deriveTimelineEntries,
5460 deriveActiveWorkStartedAt,
5561 deriveActivePlanState,
62 findSidebarProposedPlan,
5663 findLatestProposedPlan,
5764 deriveWorkLogEntries,
5865 hasActionableProposedPlan,
......@@ -146,7 +153,8 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded
146153import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
147154import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
148155import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
149import { CodexTraitsPicker } from "./chat/CodexTraitsPicker";
156import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker";
157import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker";
150158import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
151159import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
152160import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
......@@ -181,6 +189,17 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
181189const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
182190const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
183191const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
192
193function formatOutgoingPrompt(params: {
194 provider: ProviderKind;
195 effort: string | null;
196 text: string;
197}): string {
198 if (params.provider === "claudeAgent" && params.effort === "ultrathink") {
199 return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);
200 }
201 return params.text;
202}
184203const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
185204const SCRIPT_TERMINAL_COLS = 120;
186205const SCRIPT_TERMINAL_ROWS = 30;
......@@ -255,8 +274,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
255274 const setComposerDraftInteractionMode = useComposerDraftStore(
256275 (store) => store.setInteractionMode,
257276 );
258 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
259 const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode);
260277 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
261278 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
262279 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
......@@ -579,7 +596,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
579596 selectedProvider,
580597 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
581598 );
582 const customModelsForSelectedProvider = settings.customCodexModels;
599 const customModelsByProvider = useMemo(
600 () => ({
601 codex: settings.customCodexModels,
602 claudeAgent: settings.customClaudeModels,
603 }),
604 [settings.customClaudeModels, settings.customCodexModels],
605 );
606 const customModelsForSelectedProvider = customModelsByProvider[selectedProvider];
583607 const selectedModel = useMemo(() => {
584608 const draftModel = composerDraft.model;
585609 if (!draftModel) {
......@@ -591,21 +615,53 @@ export default function ChatView({ threadId }: ChatViewProps) {
591615 draftModel,
592616 ) as ModelSlug;
593617 }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]);
594 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
595 const supportsReasoningEffort = reasoningOptions.length > 0;
596 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
597 const selectedCodexFastModeEnabled =
598 selectedProvider === "codex" ? composerDraft.codexFastMode : false;
618 const draftModelOptions = composerDraft.modelOptions;
619 const selectedCodexEffort =
620 selectedProvider === "codex"
621 ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ??
622 getDefaultReasoningEffort("codex"))
623 : null;
624 const selectedClaudeReasoningOptions =
625 selectedProvider === "claudeAgent"
626 ? getReasoningEffortOptions("claudeAgent", selectedModel)
627 : ([] as const);
628 const selectedClaudeBaseEffort =
629 selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0
630 ? (() => {
631 const draftEffort = resolveReasoningEffortForProvider(
632 "claudeAgent",
633 draftModelOptions?.claudeAgent?.effort,
634 );
635 if (
636 draftEffort &&
637 draftEffort !== "ultrathink" &&
638 selectedClaudeReasoningOptions.includes(draftEffort)
639 ) {
640 return draftEffort;
641 }
642 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
643 return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null;
644 })()
645 : null;
646 const isClaudeUltrathink =
647 selectedProvider === "claudeAgent" &&
648 supportsClaudeUltrathinkKeyword(selectedModel) &&
649 isClaudeUltrathinkPrompt(prompt);
650 const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort;
599651 const selectedModelOptionsForDispatch = useMemo(() => {
600 if (selectedProvider !== "codex") {
601 return undefined;
652 if (selectedProvider === "codex") {
653 const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex);
654 return codexOptions ? { codex: codexOptions } : undefined;
602655 }
603 const codexOptions = {
604 ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}),
605 ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}),
606 };
607 return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
608 }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);
656 if (selectedProvider === "claudeAgent") {
657 const claudeOptions = normalizeClaudeModelOptions(
658 selectedModel,
659 draftModelOptions?.claudeAgent,
660 );
661 return claudeOptions ? { claudeAgent: claudeOptions } : undefined;
662 }
663 return undefined;
664 }, [draftModelOptions, selectedModel, selectedProvider]);
609665 const providerOptionsForDispatch = useMemo(() => {
610666 if (!settings.codexBinaryPath && !settings.codexHomePath) {
611667 return undefined;
......@@ -714,6 +770,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
714770 activeLatestTurn?.turnId ?? null,
715771 );
716772 }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);
773 const sidebarProposedPlan = useMemo(
774 () =>
775 findSidebarProposedPlan({
776 threads,
777 latestTurn: activeLatestTurn,
778 latestTurnSettled,
779 threadId: activeThread?.id ?? null,
780 }),
781 [activeLatestTurn, activeThread?.id, latestTurnSettled, threads],
782 );
717783 const activePlan = useMemo(
718784 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
719785 [activeLatestTurn?.turnId, threadActivities],
......@@ -1554,7 +1620,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15541620 const togglePlanSidebar = useCallback(() => {
15551621 setPlanSidebarOpen((open) => {
15561622 if (open) {
1557 const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
1623 const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
15581624 if (turnKey) {
15591625 planSidebarDismissedForTurnRef.current = turnKey;
15601626 }
......@@ -1563,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15631629 }
15641630 return !open;
15651631 });
1566 }, [activePlan?.turnId, activeProposedPlan?.turnId]);
1632 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
15671633
15681634 const persistThreadSettingsForNextTurn = useCallback(
15691635 async (input: {
......@@ -2361,7 +2427,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23612427 ? parseStandaloneComposerSlashCommand(trimmed)
23622428 : null;
23632429 if (standaloneSlashCommand) {
2364 await handleInteractionModeChange(standaloneSlashCommand);
2430 handleInteractionModeChange(standaloneSlashCommand);
23652431 promptRef.current = "";
23662432 clearComposerDraftContent(activeThread.id);
23672433 setComposerHighlightedItemId(null);
......@@ -2414,6 +2480,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
24142480 );
24152481 const messageIdForSend = newMessageId();
24162482 const messageCreatedAt = new Date().toISOString();
2483 const outgoingMessageText = formatOutgoingPrompt({
2484 provider: selectedProvider,
2485 effort: selectedPromptEffort,
2486 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2487 });
24172488 const turnAttachmentsPromise = Promise.all(
24182489 composerImagesSnapshot.map(async (image) => ({
24192490 type: "image" as const,
......@@ -2436,7 +2507,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24362507 {
24372508 id: messageIdForSend,
24382509 role: "user",
2439 text: messageTextForSend,
2510 text: outgoingMessageText,
24402511 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
24412512 createdAt: messageCreatedAt,
24422513 streaming: false,
......@@ -2587,7 +2658,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25872658 message: {
25882659 messageId: messageIdForSend,
25892660 role: "user",
2590 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2661 text: outgoingMessageText,
25912662 attachments: turnAttachments,
25922663 },
25932664 model: selectedModel || undefined,
......@@ -2830,6 +2901,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
28302901 const threadIdForSend = activeThread.id;
28312902 const messageIdForSend = newMessageId();
28322903 const messageCreatedAt = new Date().toISOString();
2904 const outgoingMessageText = formatOutgoingPrompt({
2905 provider: selectedProvider,
2906 effort: selectedPromptEffort,
2907 text: trimmed,
2908 });
28332909
28342910 sendInFlightRef.current = true;
28352911 beginSendPhase("sending-turn");
......@@ -2839,7 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28392915 {
28402916 id: messageIdForSend,
28412917 role: "user",
2842 text: trimmed,
2918 text: outgoingMessageText,
28432919 createdAt: messageCreatedAt,
28442920 streaming: false,
28452921 },
......@@ -2867,7 +2943,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28672943 message: {
28682944 messageId: messageIdForSend,
28692945 role: "user",
2870 text: trimmed,
2946 text: outgoingMessageText,
28712947 attachments: [],
28722948 },
28732949 provider: selectedProvider,
......@@ -2920,6 +2996,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29202996 persistThreadSettingsForNextTurn,
29212997 resetSendPhase,
29222998 runtimeMode,
2999 selectedPromptEffort,
29233000 selectedModel,
29243001 selectedModelOptionsForDispatch,
29253002 providerOptionsForDispatch,
......@@ -2949,6 +3026,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
29493026 const nextThreadId = newThreadId();
29503027 const planMarkdown = activeProposedPlan.planMarkdown;
29513028 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
3029 const outgoingImplementationPrompt = formatOutgoingPrompt({
3030 provider: selectedProvider,
3031 effort: selectedPromptEffort,
3032 text: implementationPrompt,
3033 });
29523034 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));
29533035 const nextThreadModel: ModelSlug =
29543036 selectedModel ||
......@@ -2985,7 +3067,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29853067 message: {
29863068 messageId: newMessageId(),
29873069 role: "user",
2988 text: implementationPrompt,
3070 text: outgoingImplementationPrompt,
29893071 attachments: [],
29903072 },
29913073 provider: selectedProvider,
......@@ -3043,6 +3125,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30433125 navigate,
30443126 resetSendPhase,
30453127 runtimeMode,
3128 selectedPromptEffort,
30463129 selectedModel,
30473130 selectedModelOptionsForDispatch,
30483131 providerOptionsForDispatch,
......@@ -3061,32 +3144,34 @@ export default function ChatView({ threadId }: ChatViewProps) {
30613144 setComposerDraftProvider(activeThread.id, provider);
30623145 setComposerDraftModel(
30633146 activeThread.id,
3064 resolveAppModelSelection(provider, settings.customCodexModels, model),
3147 resolveAppModelSelection(provider, customModelsByProvider[provider], model),
30653148 );
30663149 scheduleComposerFocus();
30673150 },
30683151 [
30693152 activeThread,
3153 customModelsByProvider,
30703154 lockedProvider,
30713155 scheduleComposerFocus,
30723156 setComposerDraftModel,
30733157 setComposerDraftProvider,
3074 settings.customCodexModels,
30753158 ],
30763159 );
3077 const onEffortSelect = useCallback(
3078 (effort: CodexReasoningEffort) => {
3079 setComposerDraftEffort(threadId, effort);
3080 scheduleComposerFocus();
3081 },
3082 [scheduleComposerFocus, setComposerDraftEffort, threadId],
3083 );
3084 const onCodexFastModeChange = useCallback(
3085 (enabled: boolean) => {
3086 setComposerDraftCodexFastMode(threadId, enabled);
3160 const setPromptFromTraits = useCallback(
3161 (nextPrompt: string) => {
3162 const currentPrompt = promptRef.current;
3163 if (nextPrompt === currentPrompt) {
3164 scheduleComposerFocus();
3165 return;
3166 }
3167 promptRef.current = nextPrompt;
3168 setPrompt(nextPrompt);
3169 const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
3170 setComposerCursor(nextCursor);
3171 setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
30873172 scheduleComposerFocus();
30883173 },
3089 [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
3174 [scheduleComposerFocus, setPrompt],
30903175 );
30913176 const onEnvModeChange = useCallback(
30923177 (mode: DraftThreadEnvMode) => {
......@@ -3519,65 +3604,71 @@ export default function ChatView({ threadId }: ChatViewProps) {
35193604 data-chat-composer-form="true"
35203605 >
35213606 <div
3522 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45 ${
3523 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
3524 }`}
3607 className={cn(
3608 "group rounded-[22px] p-px transition-colors duration-200",
3609 isClaudeUltrathink ? "ultrathink-frame" : undefined,
3610 )}
35253611 onDragEnter={onComposerDragEnter}
35263612 onDragOver={onComposerDragOver}
35273613 onDragLeave={onComposerDragLeave}
35283614 onDrop={onComposerDrop}
35293615 >
3530 {activePendingApproval ? (
3531 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3532 <ComposerPendingApprovalPanel
3533 approval={activePendingApproval}
3534 pendingCount={pendingApprovals.length}
3535 />
3536 </div>
3537 ) : pendingUserInputs.length > 0 ? (
3538 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3539 <ComposerPendingUserInputPanel
3540 pendingUserInputs={pendingUserInputs}
3541 respondingRequestIds={respondingRequestIds}
3542 answers={activePendingDraftAnswers}
3543 questionIndex={activePendingQuestionIndex}
3544 onSelectOption={onSelectActivePendingUserInputOption}
3545 onAdvance={onAdvanceActivePendingUserInput}
3546 />
3547 </div>
3548 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
3549 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3550 <ComposerPlanFollowUpBanner
3551 key={activeProposedPlan.id}
3552 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3553 />
3554 </div>
3555 ) : null}
3556
3557 {/* Textarea area */}
35583616 <div
35593617 className={cn(
3560 "relative px-3 pb-2 sm:px-4",
3561 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
3618 "rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45",
3619 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
3620 isClaudeUltrathink ? "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" : "",
35623621 )}
35633622 >
3564 {composerMenuOpen && !isComposerApprovalState && (
3565 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
3566 <ComposerCommandMenu
3567 items={composerMenuItems}
3568 resolvedTheme={resolvedTheme}
3569 isLoading={isComposerMenuLoading}
3570 triggerKind={composerTriggerKind}
3571 activeItemId={activeComposerMenuItem?.id ?? null}
3572 onHighlightedItemChange={onComposerMenuItemHighlighted}
3573 onSelect={onSelectComposerItem}
3623 {activePendingApproval ? (
3624 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3625 <ComposerPendingApprovalPanel
3626 approval={activePendingApproval}
3627 pendingCount={pendingApprovals.length}
35743628 />
35753629 </div>
3576 )}
3630 ) : pendingUserInputs.length > 0 ? (
3631 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3632 <ComposerPendingUserInputPanel
3633 pendingUserInputs={pendingUserInputs}
3634 respondingRequestIds={respondingRequestIds}
3635 answers={activePendingDraftAnswers}
3636 questionIndex={activePendingQuestionIndex}
3637 onSelectOption={onSelectActivePendingUserInputOption}
3638 onAdvance={onAdvanceActivePendingUserInput}
3639 />
3640 </div>
3641 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
3642 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3643 <ComposerPlanFollowUpBanner
3644 key={activeProposedPlan.id}
3645 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3646 />
3647 </div>
3648 ) : null}
3649 <div
3650 className={cn(
3651 "relative px-3 pb-2 sm:px-4",
3652 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
3653 )}
3654 >
3655 {composerMenuOpen && !isComposerApprovalState && (
3656 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
3657 <ComposerCommandMenu
3658 items={composerMenuItems}
3659 resolvedTheme={resolvedTheme}
3660 isLoading={isComposerMenuLoading}
3661 triggerKind={composerTriggerKind}
3662 activeItemId={activeComposerMenuItem?.id ?? null}
3663 onHighlightedItemChange={onComposerMenuItemHighlighted}
3664 onSelect={onSelectComposerItem}
3665 />
3666 </div>
3667 )}
35773668
3578 {!isComposerApprovalState && pendingUserInputs.length === 0 && (
3579 <>
3580 {composerImages.length > 0 && (
3669 {!isComposerApprovalState &&
3670 pendingUserInputs.length === 0 &&
3671 composerImages.length > 0 && (
35813672 <div className="mb-3 flex flex-wrap gap-2">
35823673 {composerImages.map((image) => (
35833674 <div
......@@ -3644,355 +3735,374 @@ export default function ChatView({ threadId }: ChatViewProps) {
36443735 ))}
36453736 </div>
36463737 )}
3647 </>
3648 )}
3649 <ComposerPromptEditor
3650 ref={composerEditorRef}
3651 value={
3652 isComposerApprovalState
3653 ? ""
3654 : activePendingProgress
3655 ? activePendingProgress.customAnswer
3656 : prompt
3657 }
3658 cursor={composerCursor}
3659 terminalContexts={
3660 !isComposerApprovalState && pendingUserInputs.length === 0
3661 ? composerTerminalContexts
3662 : []
3663 }
3664 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
3665 onChange={onPromptChange}
3666 onCommandKeyDown={onComposerCommandKey}
3667 onPaste={onComposerPaste}
3668 placeholder={
3669 isComposerApprovalState
3670 ? (activePendingApproval?.detail ??
3671 "Resolve this approval request to continue")
3672 : activePendingProgress
3673 ? "Type your own answer, or leave this blank to use the selected option"
3674 : showPlanFollowUpPrompt && activeProposedPlan
3675 ? "Add feedback to refine the plan, or leave this blank to implement it"
3676 : phase === "disconnected"
3677 ? "Ask for follow-up changes or attach images"
3678 : "Ask anything, @tag files/folders, or use / to show available commands"
3679 }
3680 disabled={isConnecting || isComposerApprovalState}
3681 />
3682 </div>
3683
3684 {/* Bottom toolbar */}
3685 {activePendingApproval ? (
3686 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
3687 <ComposerPendingApprovalActions
3688 requestId={activePendingApproval.requestId}
3689 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3690 onRespondToApproval={onRespondToApproval}
3738 <ComposerPromptEditor
3739 ref={composerEditorRef}
3740 value={
3741 isComposerApprovalState
3742 ? ""
3743 : activePendingProgress
3744 ? activePendingProgress.customAnswer
3745 : prompt
3746 }
3747 cursor={composerCursor}
3748 terminalContexts={
3749 !isComposerApprovalState && pendingUserInputs.length === 0
3750 ? composerTerminalContexts
3751 : []
3752 }
3753 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
3754 onChange={onPromptChange}
3755 onCommandKeyDown={onComposerCommandKey}
3756 onPaste={onComposerPaste}
3757 placeholder={
3758 isComposerApprovalState
3759 ? (activePendingApproval?.detail ??
3760 "Resolve this approval request to continue")
3761 : activePendingProgress
3762 ? "Type your own answer, or leave this blank to use the selected option"
3763 : showPlanFollowUpPrompt && activeProposedPlan
3764 ? "Add feedback to refine the plan, or leave this blank to implement it"
3765 : phase === "disconnected"
3766 ? "Ask for follow-up changes or attach images"
3767 : "Ask anything, @tag files/folders, or use / to show available commands"
3768 }
3769 disabled={isConnecting || isComposerApprovalState}
36913770 />
36923771 </div>
3693 ) : (
3694 <div
3695 data-chat-composer-footer="true"
3696 className={cn(
3697 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",
3698 isComposerFooterCompact
3699 ? "gap-1.5"
3700 : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",
3701 )}
3702 >
3772
3773 {/* Bottom toolbar */}
3774 {activePendingApproval ? (
3775 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
3776 <ComposerPendingApprovalActions
3777 requestId={activePendingApproval.requestId}
3778 isResponding={respondingRequestIds.includes(
3779 activePendingApproval.requestId,
3780 )}
3781 onRespondToApproval={onRespondToApproval}
3782 />
3783 </div>
3784 ) : (
37033785 <div
3786 data-chat-composer-footer="true"
37043787 className={cn(
3705 "flex min-w-0 flex-1 items-center",
3788 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",
37063789 isComposerFooterCompact
3707 ? "gap-1 overflow-hidden"
3708 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
3790 ? "gap-1.5"
3791 : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",
37093792 )}
37103793 >
3711 {/* Provider/model picker */}
3712 <ProviderModelPicker
3713 compact={isComposerFooterCompact}
3714 provider={selectedProvider}
3715 model={selectedModelForPickerWithCustomFallback}
3716 lockedProvider={lockedProvider}
3717 modelOptionsByProvider={modelOptionsByProvider}
3718 onProviderModelChange={onProviderModelSelect}
3719 />
3720
3721 {isComposerFooterCompact ? (
3722 <CompactComposerControlsMenu
3723 activePlan={Boolean(activePlan || activeProposedPlan || planSidebarOpen)}
3724 interactionMode={interactionMode}
3725 planSidebarOpen={planSidebarOpen}
3726 runtimeMode={runtimeMode}
3727 selectedEffort={selectedEffort}
3728 selectedProvider={selectedProvider}
3729 selectedCodexFastModeEnabled={selectedCodexFastModeEnabled}
3730 reasoningOptions={reasoningOptions}
3731 onEffortSelect={onEffortSelect}
3732 onCodexFastModeChange={onCodexFastModeChange}
3733 onToggleInteractionMode={toggleInteractionMode}
3734 onTogglePlanSidebar={togglePlanSidebar}
3735 onToggleRuntimeMode={toggleRuntimeMode}
3794 <div
3795 className={cn(
3796 "flex min-w-0 flex-1 items-center",
3797 isComposerFooterCompact
3798 ? "gap-1 overflow-hidden"
3799 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
3800 )}
3801 >
3802 {/* Provider/model picker */}
3803 <ProviderModelPicker
3804 compact={isComposerFooterCompact}
3805 provider={selectedProvider}
3806 model={selectedModelForPickerWithCustomFallback}
3807 lockedProvider={lockedProvider}
3808 modelOptionsByProvider={modelOptionsByProvider}
3809 ultrathinkActive={isClaudeUltrathink}
3810 onProviderModelChange={onProviderModelSelect}
37363811 />
3737 ) : (
3738 <>
3739 {selectedProvider === "codex" && selectedEffort != null ? (
3740 <>
3741 <Separator
3742 orientation="vertical"
3743 className="mx-0.5 hidden h-4 sm:block"
3744 />
3745 <CodexTraitsPicker
3746 effort={selectedEffort}
3747 fastModeEnabled={selectedCodexFastModeEnabled}
3748 options={reasoningOptions}
3749 onEffortChange={onEffortSelect}
3750 onFastModeChange={onCodexFastModeChange}
3751 />
3752 </>
3753 ) : null}
3754
3755 <Separator
3756 orientation="vertical"
3757 className="mx-0.5 hidden h-4 sm:block"
3758 />
37593812
3760 <Button
3761 variant="ghost"
3762 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3763 size="sm"
3764 type="button"
3765 onClick={toggleInteractionMode}
3766 title={
3767 interactionMode === "plan"
3768 ? "Plan mode — click to return to normal chat mode"
3769 : "Default mode — click to enter plan mode"
3813 {isComposerFooterCompact ? (
3814 <CompactComposerControlsMenu
3815 activePlan={Boolean(
3816 activePlan || sidebarProposedPlan || planSidebarOpen,
3817 )}
3818 interactionMode={interactionMode}
3819 planSidebarOpen={planSidebarOpen}
3820 runtimeMode={runtimeMode}
3821 traitsMenuContent={
3822 selectedProvider === "codex" ? (
3823 <CodexTraitsMenuContent threadId={threadId} />
3824 ) : selectedProvider === "claudeAgent" ? (
3825 <ClaudeTraitsMenuContent
3826 threadId={threadId}
3827 model={selectedModel}
3828 onPromptChange={setPromptFromTraits}
3829 />
3830 ) : null
37703831 }
3771 >
3772 <BotIcon />
3773 <span className="sr-only sm:not-sr-only">
3774 {interactionMode === "plan" ? "Plan" : "Chat"}
3775 </span>
3776 </Button>
3777
3778 <Separator
3779 orientation="vertical"
3780 className="mx-0.5 hidden h-4 sm:block"
3832 onToggleInteractionMode={toggleInteractionMode}
3833 onTogglePlanSidebar={togglePlanSidebar}
3834 onToggleRuntimeMode={toggleRuntimeMode}
37813835 />
3836 ) : (
3837 <>
3838 {selectedProvider === "codex" ? (
3839 <>
3840 <Separator
3841 orientation="vertical"
3842 className="mx-0.5 hidden h-4 sm:block"
3843 />
3844 <CodexTraitsPicker threadId={threadId} />
3845 </>
3846 ) : selectedProvider === "claudeAgent" ? (
3847 <>
3848 <Separator
3849 orientation="vertical"
3850 className="mx-0.5 hidden h-4 sm:block"
3851 />
3852 <ClaudeTraitsPicker
3853 threadId={threadId}
3854 model={selectedModel}
3855 onPromptChange={setPromptFromTraits}
3856 />
3857 </>
3858 ) : null}
37823859
3783 <Button
3784 variant="ghost"
3785 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3786 size="sm"
3787 type="button"
3788 onClick={() =>
3789 void handleRuntimeModeChange(
3790 runtimeMode === "full-access" ? "approval-required" : "full-access",
3791 )
3792 }
3793 title={
3794 runtimeMode === "full-access"
3795 ? "Full access — click to require approvals"
3796 : "Approval required — click for full access"
3797 }
3798 >
3799 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3800 <span className="sr-only sm:not-sr-only">
3801 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3802 </span>
3803 </Button>
3804
3805 {activePlan || activeProposedPlan || planSidebarOpen ? (
3806 <>
3807 <Separator
3808 orientation="vertical"
3809 className="mx-0.5 hidden h-4 sm:block"
3810 />
3811 <Button
3812 variant="ghost"
3813 className={cn(
3814 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3815 planSidebarOpen
3816 ? "text-blue-400 hover:text-blue-300"
3817 : "text-muted-foreground/70 hover:text-foreground/80",
3818 )}
3819 size="sm"
3820 type="button"
3821 onClick={togglePlanSidebar}
3822 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3823 >
3824 <ListTodoIcon />
3825 <span className="sr-only sm:not-sr-only">Plan</span>
3826 </Button>
3827 </>
3828 ) : null}
3829 </>
3830 )}
3831 </div>
3860 <Separator
3861 orientation="vertical"
3862 className="mx-0.5 hidden h-4 sm:block"
3863 />
38323864
3833 {/* Right side: send / stop button */}
3834 <div
3835 data-chat-composer-actions="right"
3836 className="flex shrink-0 items-center gap-2"
3837 >
3838 {isPreparingWorktree ? (
3839 <span className="text-muted-foreground/70 text-xs">
3840 Preparing worktree...
3841 </span>
3842 ) : null}
3843 {activePendingProgress ? (
3844 <div className="flex items-center gap-2">
3845 {activePendingProgress.questionIndex > 0 ? (
38463865 <Button
3866 variant="ghost"
3867 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
38473868 size="sm"
3848 variant="outline"
3849 className="rounded-full"
3850 onClick={onPreviousActivePendingUserInputQuestion}
3851 disabled={activePendingIsResponding}
3869 type="button"
3870 onClick={toggleInteractionMode}
3871 title={
3872 interactionMode === "plan"
3873 ? "Plan mode — click to return to normal chat mode"
3874 : "Default mode — click to enter plan mode"
3875 }
38523876 >
3853 Previous
3877 <BotIcon />
3878 <span className="sr-only sm:not-sr-only">
3879 {interactionMode === "plan" ? "Plan" : "Chat"}
3880 </span>
38543881 </Button>
3855 ) : null}
3856 <Button
3857 type="submit"
3858 size="sm"
3859 className="rounded-full px-4"
3860 disabled={
3861 activePendingIsResponding ||
3862 (activePendingProgress.isLastQuestion
3863 ? !activePendingResolvedAnswers
3864 : !activePendingProgress.canAdvance)
3865 }
3866 >
3867 {activePendingIsResponding
3868 ? "Submitting..."
3869 : activePendingProgress.isLastQuestion
3870 ? "Submit answers"
3871 : "Next question"}
3872 </Button>
3873 </div>
3874 ) : phase === "running" ? (
3875 <button
3876 type="button"
3877 className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
3878 onClick={() => void onInterrupt()}
3879 aria-label="Stop generation"
3880 >
3881 <svg
3882 width="12"
3883 height="12"
3884 viewBox="0 0 12 12"
3885 fill="currentColor"
3886 aria-hidden="true"
3887 >
3888 <rect x="2" y="2" width="8" height="8" rx="1.5" />
3889 </svg>
3890 </button>
3891 ) : pendingUserInputs.length === 0 ? (
3892 showPlanFollowUpPrompt ? (
3893 prompt.trim().length > 0 ? (
3882
3883 <Separator
3884 orientation="vertical"
3885 className="mx-0.5 hidden h-4 sm:block"
3886 />
3887
3888 <Button
3889 variant="ghost"
3890 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3891 size="sm"
3892 type="button"
3893 onClick={() =>
3894 void handleRuntimeModeChange(
3895 runtimeMode === "full-access"
3896 ? "approval-required"
3897 : "full-access",
3898 )
3899 }
3900 title={
3901 runtimeMode === "full-access"
3902 ? "Full access — click to require approvals"
3903 : "Approval required — click for full access"
3904 }
3905 >
3906 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3907 <span className="sr-only sm:not-sr-only">
3908 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3909 </span>
3910 </Button>
3911
3912 {activePlan || sidebarProposedPlan || planSidebarOpen ? (
3913 <>
3914 <Separator
3915 orientation="vertical"
3916 className="mx-0.5 hidden h-4 sm:block"
3917 />
3918 <Button
3919 variant="ghost"
3920 className={cn(
3921 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3922 planSidebarOpen
3923 ? "text-blue-400 hover:text-blue-300"
3924 : "text-muted-foreground/70 hover:text-foreground/80",
3925 )}
3926 size="sm"
3927 type="button"
3928 onClick={togglePlanSidebar}
3929 title={
3930 planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"
3931 }
3932 >
3933 <ListTodoIcon />
3934 <span className="sr-only sm:not-sr-only">Plan</span>
3935 </Button>
3936 </>
3937 ) : null}
3938 </>
3939 )}
3940 </div>
3941
3942 {/* Right side: send / stop button */}
3943 <div
3944 data-chat-composer-actions="right"
3945 className="flex shrink-0 items-center gap-2"
3946 >
3947 {isPreparingWorktree ? (
3948 <span className="text-muted-foreground/70 text-xs">
3949 Preparing worktree...
3950 </span>
3951 ) : null}
3952 {activePendingProgress ? (
3953 <div className="flex items-center gap-2">
3954 {activePendingProgress.questionIndex > 0 ? (
3955 <Button
3956 size="sm"
3957 variant="outline"
3958 className="rounded-full"
3959 onClick={onPreviousActivePendingUserInputQuestion}
3960 disabled={activePendingIsResponding}
3961 >
3962 Previous
3963 </Button>
3964 ) : null}
38943965 <Button
38953966 type="submit"
38963967 size="sm"
3897 className="h-9 rounded-full px-4 sm:h-8"
3898 disabled={isSendBusy || isConnecting}
3968 className="rounded-full px-4"
3969 disabled={
3970 activePendingIsResponding ||
3971 (activePendingProgress.isLastQuestion
3972 ? !activePendingResolvedAnswers
3973 : !activePendingProgress.canAdvance)
3974 }
38993975 >
3900 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
3976 {activePendingIsResponding
3977 ? "Submitting..."
3978 : activePendingProgress.isLastQuestion
3979 ? "Submit answers"
3980 : "Next question"}
39013981 </Button>
3902 ) : (
3903 <div className="flex items-center">
3982 </div>
3983 ) : phase === "running" ? (
3984 <button
3985 type="button"
3986 className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
3987 onClick={() => void onInterrupt()}
3988 aria-label="Stop generation"
3989 >
3990 <svg
3991 width="12"
3992 height="12"
3993 viewBox="0 0 12 12"
3994 fill="currentColor"
3995 aria-hidden="true"
3996 >
3997 <rect x="2" y="2" width="8" height="8" rx="1.5" />
3998 </svg>
3999 </button>
4000 ) : pendingUserInputs.length === 0 ? (
4001 showPlanFollowUpPrompt ? (
4002 prompt.trim().length > 0 ? (
39044003 <Button
39054004 type="submit"
39064005 size="sm"
3907 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
4006 className="h-9 rounded-full px-4 sm:h-8"
39084007 disabled={isSendBusy || isConnecting}
39094008 >
3910 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
4009 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
39114010 </Button>
3912 <Menu>
3913 <MenuTrigger
3914 render={
3915 <Button
3916 size="sm"
3917 variant="default"
3918 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
3919 aria-label="Implementation actions"
3920 disabled={isSendBusy || isConnecting}
3921 />
3922 }
4011 ) : (
4012 <div className="flex items-center">
4013 <Button
4014 type="submit"
4015 size="sm"
4016 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
4017 disabled={isSendBusy || isConnecting}
39234018 >
3924 <ChevronDownIcon className="size-3.5" />
3925 </MenuTrigger>
3926 <MenuPopup align="end" side="top">
3927 <MenuItem
3928 disabled={isSendBusy || isConnecting}
3929 onClick={() => void onImplementPlanInNewThread()}
4019 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
4020 </Button>
4021 <Menu>
4022 <MenuTrigger
4023 render={
4024 <Button
4025 size="sm"
4026 variant="default"
4027 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
4028 aria-label="Implementation actions"
4029 disabled={isSendBusy || isConnecting}
4030 />
4031 }
39304032 >
3931 Implement in a new thread
3932 </MenuItem>
3933 </MenuPopup>
3934 </Menu>
3935 </div>
4033 <ChevronDownIcon className="size-3.5" />
4034 </MenuTrigger>
4035 <MenuPopup align="end" side="top">
4036 <MenuItem
4037 disabled={isSendBusy || isConnecting}
4038 onClick={() => void onImplementPlanInNewThread()}
4039 >
4040 Implement in a new thread
4041 </MenuItem>
4042 </MenuPopup>
4043 </Menu>
4044 </div>
4045 )
4046 ) : (
4047 <button
4048 type="submit"
4049 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
4050 disabled={
4051 isSendBusy || isConnecting || !composerSendState.hasSendableContent
4052 }
4053 aria-label={
4054 isConnecting
4055 ? "Connecting"
4056 : isPreparingWorktree
4057 ? "Preparing worktree"
4058 : isSendBusy
4059 ? "Sending"
4060 : "Send message"
4061 }
4062 >
4063 {isConnecting || isSendBusy ? (
4064 <svg
4065 width="14"
4066 height="14"
4067 viewBox="0 0 14 14"
4068 fill="none"
4069 className="animate-spin"
4070 aria-hidden="true"
4071 >
4072 <circle
4073 cx="7"
4074 cy="7"
4075 r="5.5"
4076 stroke="currentColor"
4077 strokeWidth="1.5"
4078 strokeLinecap="round"
4079 strokeDasharray="20 12"
4080 />
4081 </svg>
4082 ) : (
4083 <svg
4084 width="14"
4085 height="14"
4086 viewBox="0 0 14 14"
4087 fill="none"
4088 aria-hidden="true"
4089 >
4090 <path
4091 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
4092 stroke="currentColor"
4093 strokeWidth="1.8"
4094 strokeLinecap="round"
4095 strokeLinejoin="round"
4096 />
4097 </svg>
4098 )}
4099 </button>
39364100 )
3937 ) : (
3938 <button
3939 type="submit"
3940 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
3941 disabled={
3942 isSendBusy || isConnecting || !composerSendState.hasSendableContent
3943 }
3944 aria-label={
3945 isConnecting
3946 ? "Connecting"
3947 : isPreparingWorktree
3948 ? "Preparing worktree"
3949 : isSendBusy
3950 ? "Sending"
3951 : "Send message"
3952 }
3953 >
3954 {isConnecting || isSendBusy ? (
3955 <svg
3956 width="14"
3957 height="14"
3958 viewBox="0 0 14 14"
3959 fill="none"
3960 className="animate-spin"
3961 aria-hidden="true"
3962 >
3963 <circle
3964 cx="7"
3965 cy="7"
3966 r="5.5"
3967 stroke="currentColor"
3968 strokeWidth="1.5"
3969 strokeLinecap="round"
3970 strokeDasharray="20 12"
3971 />
3972 </svg>
3973 ) : (
3974 <svg
3975 width="14"
3976 height="14"
3977 viewBox="0 0 14 14"
3978 fill="none"
3979 aria-hidden="true"
3980 >
3981 <path
3982 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
3983 stroke="currentColor"
3984 strokeWidth="1.8"
3985 strokeLinecap="round"
3986 strokeLinejoin="round"
3987 />
3988 </svg>
3989 )}
3990 </button>
3991 )
3992 ) : null}
4101 ) : null}
4102 </div>
39934103 </div>
3994 </div>
3995 )}
4104 )}
4105 </div>
39964106 </div>
39974107 </form>
39984108 </div>
......@@ -4029,14 +4139,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
40294139 {planSidebarOpen ? (
40304140 <PlanSidebar
40314141 activePlan={activePlan}
4032 activeProposedPlan={activeProposedPlan}
4142 activeProposedPlan={sidebarProposedPlan}
40334143 markdownCwd={gitCwd ?? undefined}
40344144 workspaceRoot={activeProject?.cwd ?? undefined}
40354145 timestampFormat={timestampFormat}
40364146 onClose={() => {
40374147 setPlanSidebarOpen(false);
40384148 // Track that the user explicitly dismissed for this turn so auto-open won't fight them.
4039 const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
4149 const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
40404150 if (turnKey) {
40414151 planSidebarDismissedForTurnRef.current = turnKey;
40424152 }
apps/web/src/components/Icons.tsx+1-1
......@@ -146,7 +146,7 @@ export const OpenAI: Icon = (props) => (
146146export const ClaudeAI: Icon = (props) => (
147147 <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257">
148148 <path
149 fill="#D97757"
149 fill="currentColor"
150150 d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
151151 />
152152 </svg>
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx created+176
......@@ -0,0 +1,176 @@
1import "../../index.css";
2
3import { ThreadId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker";
9import { useComposerDraftStore } from "../../composerDraftStore";
10
11async function mountPicker(props?: {
12 model?: string;
13 prompt?: string;
14 effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null;
15 thinkingEnabled?: boolean | null;
16 fastModeEnabled?: boolean;
17}) {
18 const threadId = ThreadId.makeUnsafe("thread-claude-traits");
19 const draftsByThreadId = {} as ReturnType<
20 typeof useComposerDraftStore.getState
21 >["draftsByThreadId"];
22 draftsByThreadId[threadId] = {
23 prompt: props?.prompt ?? "",
24 images: [],
25 nonPersistedImageIds: [],
26 persistedAttachments: [],
27 terminalContexts: [],
28 provider: "claudeAgent",
29 model: props?.model ?? "claude-opus-4-6",
30 modelOptions: {
31 claudeAgent: {
32 ...(props?.effort ? { effort: props.effort } : {}),
33 ...(props?.thinkingEnabled === false ? { thinking: false } : {}),
34 ...(props?.fastModeEnabled ? { fastMode: true } : {}),
35 },
36 },
37 runtimeMode: null,
38 interactionMode: null,
39 };
40 useComposerDraftStore.setState({
41 draftsByThreadId,
42 draftThreadsByThreadId: {},
43 projectDraftThreadIdByProjectId: {},
44 });
45 const host = document.createElement("div");
46 document.body.append(host);
47 const onPromptChange = vi.fn();
48 const screen = await render(
49 <ClaudeTraitsPicker
50 threadId={threadId}
51 model={props?.model ?? "claude-opus-4-6"}
52 onPromptChange={onPromptChange}
53 />,
54 { container: host },
55 );
56
57 return {
58 onPromptChange,
59 cleanup: async () => {
60 await screen.unmount();
61 host.remove();
62 },
63 };
64}
65
66describe("ClaudeTraitsPicker", () => {
67 afterEach(() => {
68 document.body.innerHTML = "";
69 useComposerDraftStore.setState({
70 draftsByThreadId: {},
71 draftThreadsByThreadId: {},
72 projectDraftThreadIdByProjectId: {},
73 });
74 });
75
76 it("shows fast mode controls for Opus", async () => {
77 const mounted = await mountPicker();
78
79 try {
80 await page.getByRole("button").click();
81
82 await vi.waitFor(() => {
83 const text = document.body.textContent ?? "";
84 expect(text).toContain("Fast Mode");
85 expect(text).toContain("off");
86 expect(text).toContain("on");
87 });
88 } finally {
89 await mounted.cleanup();
90 }
91 });
92
93 it("hides fast mode controls for non-Opus models", async () => {
94 const mounted = await mountPicker({ model: "claude-sonnet-4-6" });
95
96 try {
97 await page.getByRole("button").click();
98
99 await vi.waitFor(() => {
100 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
101 });
102 } finally {
103 await mounted.cleanup();
104 }
105 });
106
107 it("shows only the provided effort options", async () => {
108 const mounted = await mountPicker({
109 model: "claude-sonnet-4-6",
110 });
111
112 try {
113 await page.getByRole("button").click();
114
115 await vi.waitFor(() => {
116 const text = document.body.textContent ?? "";
117 expect(text).toContain("Low");
118 expect(text).toContain("Medium");
119 expect(text).toContain("High");
120 expect(text).not.toContain("Max");
121 expect(text).toContain("Ultrathink");
122 });
123 } finally {
124 await mounted.cleanup();
125 }
126 });
127
128 it("shows a thinking on/off dropdown for Haiku", async () => {
129 const mounted = await mountPicker({
130 model: "claude-haiku-4-5",
131 thinkingEnabled: true,
132 });
133
134 try {
135 await vi.waitFor(() => {
136 expect(document.body.textContent ?? "").toContain("Thinking On");
137 });
138 await page.getByRole("button").click();
139
140 await vi.waitFor(() => {
141 const text = document.body.textContent ?? "";
142 expect(text).toContain("Thinking");
143 expect(text).toContain("On (default)");
144 expect(text).toContain("Off");
145 });
146 } finally {
147 await mounted.cleanup();
148 }
149 });
150
151 it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => {
152 const mounted = await mountPicker({
153 effort: "high",
154 model: "claude-opus-4-6",
155 prompt: "Ultrathink:\nInvestigate this",
156 fastModeEnabled: false,
157 });
158
159 try {
160 await vi.waitFor(() => {
161 expect(document.body.textContent ?? "").toContain("Ultrathink");
162 expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt");
163 });
164 await page.getByRole("button").click();
165
166 await vi.waitFor(() => {
167 const text = document.body.textContent ?? "";
168 expect(text).toContain("Effort");
169 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
170 expect(text).not.toContain("Fallback Effort");
171 });
172 } finally {
173 await mounted.cleanup();
174 }
175 });
176});
apps/web/src/components/chat/ClaudeTraitsPicker.tsx created+257
......@@ -0,0 +1,257 @@
1import {
2 type ClaudeCodeEffort,
3 type ClaudeModelOptions,
4 type ProviderModelOptions,
5 type ThreadId,
6} from "@t3tools/contracts";
7import {
8 applyClaudePromptEffortPrefix,
9 getDefaultReasoningEffort,
10 getReasoningEffortOptions,
11 normalizeClaudeModelOptions,
12 resolveReasoningEffortForProvider,
13 supportsClaudeFastMode,
14 supportsClaudeThinkingToggle,
15 supportsClaudeUltrathinkKeyword,
16 isClaudeUltrathinkPrompt,
17} from "@t3tools/shared/model";
18import { memo, useState } from "react";
19import { ChevronDownIcon } from "lucide-react";
20import { Button } from "../ui/button";
21import {
22 Menu,
23 MenuGroup,
24 MenuPopup,
25 MenuRadioGroup,
26 MenuRadioItem,
27 MenuSeparator as MenuDivider,
28 MenuTrigger,
29} from "../ui/menu";
30import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
31
32const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = {
33 low: "Low",
34 medium: "Medium",
35 high: "High",
36 max: "Max",
37 ultrathink: "Ultrathink",
38};
39
40const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
41
42function getSelectedClaudeTraits(
43 model: string | null | undefined,
44 prompt: string,
45 modelOptions: ClaudeModelOptions | null | undefined,
46): {
47 effort: Exclude<ClaudeCodeEffort, "ultrathink"> | null;
48 thinkingEnabled: boolean | null;
49 fastModeEnabled: boolean;
50 options: ReadonlyArray<ClaudeCodeEffort>;
51 ultrathinkPromptControlled: boolean;
52 supportsFastMode: boolean;
53} {
54 const options = getReasoningEffortOptions("claudeAgent", model);
55 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude<
56 ClaudeCodeEffort,
57 "ultrathink"
58 >;
59 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);
60 const effort =
61 resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort)
62 ? resolvedEffort
63 : options.includes(defaultReasoningEffort)
64 ? defaultReasoningEffort
65 : null;
66 const thinkingEnabled = supportsClaudeThinkingToggle(model)
67 ? (modelOptions?.thinking ?? true)
68 : null;
69 const supportsFastMode = supportsClaudeFastMode(model);
70 return {
71 effort,
72 thinkingEnabled,
73 fastModeEnabled: supportsFastMode && modelOptions?.fastMode === true,
74 options,
75 ultrathinkPromptControlled:
76 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt),
77 supportsFastMode,
78 };
79}
80
81function ClaudeTraitsMenuContentImpl(props: {
82 threadId: ThreadId;
83 model: string | null | undefined;
84 onPromptChange: (prompt: string) => void;
85}) {
86 const draft = useComposerThreadDraft(props.threadId);
87 const prompt = draft.prompt;
88 const modelOptions = draft.modelOptions?.claudeAgent;
89 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);
90 const {
91 effort,
92 thinkingEnabled,
93 fastModeEnabled,
94 options,
95 ultrathinkPromptControlled,
96 supportsFastMode,
97 } = getSelectedClaudeTraits(props.model, prompt, modelOptions);
98 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");
99
100 const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => {
101 const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } =
102 draft.modelOptions ?? {};
103 const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions
104 ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions }
105 : Object.keys(otherProviderModelOptions).length > 0
106 ? otherProviderModelOptions
107 : undefined;
108 setModelOptions(props.threadId, nextProviderModelOptions);
109 };
110
111 if (effort === null && thinkingEnabled === null) {
112 return null;
113 }
114
115 return (
116 <>
117 {effort ? (
118 <>
119 <MenuGroup>
120 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Effort</div>
121 {ultrathinkPromptControlled ? (
122 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
123 Remove Ultrathink from the prompt to change effort.
124 </div>
125 ) : null}
126 <MenuRadioGroup
127 value={effort}
128 onValueChange={(value) => {
129 if (ultrathinkPromptControlled) return;
130 if (!value) return;
131 const nextEffort = options.find((option) => option === value);
132 if (!nextEffort) return;
133 if (nextEffort === "ultrathink") {
134 const nextPrompt =
135 prompt.trim().length === 0
136 ? ULTRATHINK_PROMPT_PREFIX
137 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
138 props.onPromptChange(nextPrompt);
139 return;
140 }
141 setClaudeModelOptions(
142 normalizeClaudeModelOptions(props.model, {
143 ...modelOptions,
144 effort: nextEffort,
145 }),
146 );
147 }}
148 >
149 {options.map((option) => (
150 <MenuRadioItem key={option} value={option} disabled={ultrathinkPromptControlled}>
151 {CLAUDE_EFFORT_LABELS[option]}
152 {option === defaultReasoningEffort ? " (default)" : ""}
153 </MenuRadioItem>
154 ))}
155 </MenuRadioGroup>
156 </MenuGroup>
157 </>
158 ) : thinkingEnabled !== null ? (
159 <MenuGroup>
160 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
161 <MenuRadioGroup
162 value={thinkingEnabled ? "on" : "off"}
163 onValueChange={(value) => {
164 setClaudeModelOptions(
165 normalizeClaudeModelOptions(props.model, {
166 ...modelOptions,
167 thinking: value === "on",
168 }),
169 );
170 }}
171 >
172 <MenuRadioItem value="on">On (default)</MenuRadioItem>
173 <MenuRadioItem value="off">Off</MenuRadioItem>
174 </MenuRadioGroup>
175 </MenuGroup>
176 ) : null}
177 {supportsFastMode ? (
178 <>
179 <MenuDivider />
180 <MenuGroup>
181 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
182 <MenuRadioGroup
183 value={fastModeEnabled ? "on" : "off"}
184 onValueChange={(value) => {
185 setClaudeModelOptions(
186 normalizeClaudeModelOptions(props.model, {
187 ...modelOptions,
188 fastMode: value === "on",
189 }),
190 );
191 }}
192 >
193 <MenuRadioItem value="off">off</MenuRadioItem>
194 <MenuRadioItem value="on">on</MenuRadioItem>
195 </MenuRadioGroup>
196 </MenuGroup>
197 </>
198 ) : null}
199 </>
200 );
201}
202
203export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl);
204
205export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {
206 threadId: ThreadId;
207 model: string | null | undefined;
208 onPromptChange: (prompt: string) => void;
209}) {
210 const [isMenuOpen, setIsMenuOpen] = useState(false);
211 const draft = useComposerThreadDraft(props.threadId);
212 const prompt = draft.prompt;
213 const modelOptions = draft.modelOptions?.claudeAgent;
214 const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } =
215 getSelectedClaudeTraits(props.model, prompt, modelOptions);
216 const triggerLabel = [
217 ultrathinkPromptControlled
218 ? "Ultrathink"
219 : effort
220 ? CLAUDE_EFFORT_LABELS[effort]
221 : thinkingEnabled === null
222 ? null
223 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
224 ...(supportsFastMode && fastModeEnabled ? ["Fast"] : []),
225 ]
226 .filter(Boolean)
227 .join(" · ");
228
229 return (
230 <Menu
231 open={isMenuOpen}
232 onOpenChange={(open) => {
233 setIsMenuOpen(open);
234 }}
235 >
236 <MenuTrigger
237 render={
238 <Button
239 size="sm"
240 variant="ghost"
241 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
242 />
243 }
244 >
245 <span>{triggerLabel}</span>
246 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
247 </MenuTrigger>
248 <MenuPopup align="start">
249 <ClaudeTraitsMenuContent
250 threadId={props.threadId}
251 model={props.model}
252 onPromptChange={props.onPromptChange}
253 />
254 </MenuPopup>
255 </Menu>
256 );
257});
apps/web/src/components/chat/CodexTraitsPicker.browser.tsx created+169
......@@ -0,0 +1,169 @@
1import "../../index.css";
2
3import { ProjectId, ThreadId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8import { CodexTraitsPicker } from "./CodexTraitsPicker";
9import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore";
10
11async function mountPicker(props: {
12 reasoningEffort?: "low" | "medium" | "high" | "xhigh";
13 fastModeEnabled: boolean;
14}) {
15 const threadId = ThreadId.makeUnsafe("thread-codex-traits");
16 const draftsByThreadId = {} as ReturnType<
17 typeof useComposerDraftStore.getState
18 >["draftsByThreadId"];
19 draftsByThreadId[threadId] = {
20 prompt: "",
21 images: [],
22 nonPersistedImageIds: [],
23 persistedAttachments: [],
24 terminalContexts: [],
25 provider: "codex",
26 model: null,
27 modelOptions: {
28 codex: {
29 ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}),
30 ...(props.fastModeEnabled ? { fastMode: true } : {}),
31 },
32 },
33 runtimeMode: null,
34 interactionMode: null,
35 };
36 useComposerDraftStore.setState({
37 draftsByThreadId,
38 draftThreadsByThreadId: {},
39 projectDraftThreadIdByProjectId: {
40 [ProjectId.makeUnsafe("project-codex-traits")]: threadId,
41 },
42 });
43 const host = document.createElement("div");
44 document.body.append(host);
45 const screen = await render(<CodexTraitsPicker threadId={threadId} />, { container: host });
46
47 return {
48 cleanup: async () => {
49 await screen.unmount();
50 host.remove();
51 },
52 };
53}
54
55describe("CodexTraitsPicker", () => {
56 afterEach(() => {
57 document.body.innerHTML = "";
58 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
59 useComposerDraftStore.setState({
60 draftsByThreadId: {},
61 draftThreadsByThreadId: {},
62 projectDraftThreadIdByProjectId: {},
63 });
64 });
65
66 it("shows fast mode controls", async () => {
67 const mounted = await mountPicker({
68 fastModeEnabled: false,
69 });
70
71 try {
72 await page.getByRole("button").click();
73
74 await vi.waitFor(() => {
75 const text = document.body.textContent ?? "";
76 expect(text).toContain("Fast Mode");
77 expect(text).toContain("off");
78 expect(text).toContain("on");
79 });
80 } finally {
81 await mounted.cleanup();
82 }
83 });
84
85 it("shows Fast in the trigger label when fast mode is active", async () => {
86 const mounted = await mountPicker({
87 fastModeEnabled: true,
88 });
89
90 try {
91 await vi.waitFor(() => {
92 expect(document.body.textContent ?? "").toContain("High · Fast");
93 });
94 } finally {
95 await mounted.cleanup();
96 }
97 });
98
99 it("shows only the provided effort options", async () => {
100 const mounted = await mountPicker({
101 fastModeEnabled: false,
102 });
103
104 try {
105 await page.getByRole("button").click();
106
107 await vi.waitFor(() => {
108 const text = document.body.textContent ?? "";
109 expect(text).toContain("Low");
110 expect(text).toContain("Medium");
111 expect(text).toContain("High");
112 expect(text).toContain("Extra High");
113 });
114 } finally {
115 await mounted.cleanup();
116 }
117 });
118
119 it("hydrates legacy codex persisted state into modelOptions through the picker", async () => {
120 const threadId = ThreadId.makeUnsafe("thread-codex-legacy");
121 localStorage.setItem(
122 COMPOSER_DRAFT_STORAGE_KEY,
123 JSON.stringify({
124 state: {
125 draftsByThreadId: {
126 [threadId]: {
127 prompt: "",
128 attachments: [],
129 provider: "codex",
130 model: "gpt-5.3-codex",
131 effort: "xhigh",
132 codexFastMode: true,
133 serviceTier: "fast",
134 },
135 },
136 draftThreadsByThreadId: {},
137 projectDraftThreadIdByProjectId: {},
138 },
139 version: 1,
140 }),
141 );
142 useComposerDraftStore.setState({
143 draftsByThreadId: {},
144 draftThreadsByThreadId: {},
145 projectDraftThreadIdByProjectId: {},
146 });
147
148 const host = document.createElement("div");
149 document.body.append(host);
150 const screen = await render(<CodexTraitsPicker threadId={threadId} />, { container: host });
151
152 try {
153 await useComposerDraftStore.persist.rehydrate();
154
155 await vi.waitFor(() => {
156 expect(document.body.textContent ?? "").toContain("Extra High · Fast");
157 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
158 codex: {
159 reasoningEffort: "xhigh",
160 fastMode: true,
161 },
162 });
163 });
164 } finally {
165 await screen.unmount();
166 host.remove();
167 }
168 });
169});
apps/web/src/components/chat/CodexTraitsPicker.tsx+106-57
......@@ -1,7 +1,18 @@
1import { type CodexReasoningEffort } from "@t3tools/contracts";
2import { getDefaultReasoningEffort } from "@t3tools/shared/model";
1import type {
2 CodexModelOptions,
3 CodexReasoningEffort,
4 ProviderModelOptions,
5 ThreadId,
6} from "@t3tools/contracts";
7import {
8 getDefaultReasoningEffort,
9 getReasoningEffortOptions,
10 normalizeCodexModelOptions,
11 resolveReasoningEffortForProvider,
12} from "@t3tools/shared/model";
313import { memo, useState } from "react";
414import { ChevronDownIcon } from "lucide-react";
15import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
516import { Button } from "../ui/button";
617import {
718 Menu,
......@@ -13,22 +24,101 @@ import {
1324 MenuTrigger,
1425} from "../ui/menu";
1526
16export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
27const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = {
28 low: "Low",
29 medium: "Medium",
30 high: "High",
31 xhigh: "Extra High",
32};
33
34function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): {
1735 effort: CodexReasoningEffort;
1836 fastModeEnabled: boolean;
19 options: ReadonlyArray<CodexReasoningEffort>;
20 onEffortChange: (effort: CodexReasoningEffort) => void;
21 onFastModeChange: (enabled: boolean) => void;
22}) {
23 const [isMenuOpen, setIsMenuOpen] = useState(false);
37} {
2438 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
25 const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
26 low: "Low",
27 medium: "Medium",
28 high: "High",
29 xhigh: "Extra High",
39 return {
40 effort:
41 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??
42 defaultReasoningEffort,
43 fastModeEnabled: modelOptions?.fastMode === true,
44 };
45}
46
47function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
48 const draft = useComposerThreadDraft(props.threadId);
49 const modelOptions = draft.modelOptions?.codex;
50 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);
51 const options = getReasoningEffortOptions("codex");
52 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
53 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);
54
55 const setCodexModelOptions = (nextCodexModelOptions: CodexModelOptions | undefined) => {
56 const { codex: _discardedCodex, ...otherProviderModelOptions } = draft.modelOptions ?? {};
57 const nextProviderModelOptions: ProviderModelOptions | undefined = nextCodexModelOptions
58 ? { ...otherProviderModelOptions, codex: nextCodexModelOptions }
59 : Object.keys(otherProviderModelOptions).length > 0
60 ? otherProviderModelOptions
61 : undefined;
62 setModelOptions(props.threadId, nextProviderModelOptions);
3063 };
31 const effortLabel = reasoningLabelByOption[props.effort];
64
65 return (
66 <>
67 <MenuGroup>
68 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
69 <MenuRadioGroup
70 value={effort}
71 onValueChange={(value) => {
72 if (!value) return;
73 const nextEffort = options.find((option) => option === value);
74 if (!nextEffort) return;
75 setCodexModelOptions(
76 normalizeCodexModelOptions({
77 ...modelOptions,
78 reasoningEffort: nextEffort,
79 }),
80 );
81 }}
82 >
83 {options.map((option) => (
84 <MenuRadioItem key={option} value={option}>
85 {CODEX_REASONING_LABELS[option]}
86 {option === defaultReasoningEffort ? " (default)" : ""}
87 </MenuRadioItem>
88 ))}
89 </MenuRadioGroup>
90 </MenuGroup>
91 <MenuDivider />
92 <MenuGroup>
93 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
94 <MenuRadioGroup
95 value={fastModeEnabled ? "on" : "off"}
96 onValueChange={(value) => {
97 setCodexModelOptions(
98 normalizeCodexModelOptions({
99 ...modelOptions,
100 fastMode: value === "on",
101 }),
102 );
103 }}
104 >
105 <MenuRadioItem value="off">off</MenuRadioItem>
106 <MenuRadioItem value="on">on</MenuRadioItem>
107 </MenuRadioGroup>
108 </MenuGroup>
109 </>
110 );
111}
112
113export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl);
114
115export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) {
116 const [isMenuOpen, setIsMenuOpen] = useState(false);
117 const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex;
118 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);
119 const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])]
120 .filter(Boolean)
121 .join(" · ");
32122
33123 return (
34124 <Menu
......@@ -47,53 +137,12 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
47137 }
48138 >
49139 <span className="flex min-w-0 w-full items-center gap-2 overflow-hidden">
50 {props.fastModeEnabled ? (
51 <span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
52 <span className="min-w-0 flex-1 truncate">{effortLabel}</span>
53 <span className="shrink-0 text-muted-foreground/60" aria-hidden="true">
54 ·
55 </span>
56 <span className="shrink-0">Fast</span>
57 </span>
58 ) : (
59 <span className="min-w-0 flex-1 truncate">{effortLabel}</span>
60 )}
140 {triggerLabel}
61141 <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
62142 </span>
63143 </MenuTrigger>
64144 <MenuPopup align="start">
65 <MenuGroup>
66 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
67 <MenuRadioGroup
68 value={props.effort}
69 onValueChange={(value) => {
70 if (!value) return;
71 const nextEffort = props.options.find((option) => option === value);
72 if (!nextEffort) return;
73 props.onEffortChange(nextEffort);
74 }}
75 >
76 {props.options.map((effort) => (
77 <MenuRadioItem key={effort} value={effort}>
78 {reasoningLabelByOption[effort]}
79 {effort === defaultReasoningEffort ? " (default)" : ""}
80 </MenuRadioItem>
81 ))}
82 </MenuRadioGroup>
83 </MenuGroup>
84 <MenuDivider />
85 <MenuGroup>
86 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
87 <MenuRadioGroup
88 value={props.fastModeEnabled ? "on" : "off"}
89 onValueChange={(value) => {
90 props.onFastModeChange(value === "on");
91 }}
92 >
93 <MenuRadioItem value="off">off</MenuRadioItem>
94 <MenuRadioItem value="on">on</MenuRadioItem>
95 </MenuRadioGroup>
96 </MenuGroup>
145 <CodexTraitsMenuContent threadId={props.threadId} />
97146 </MenuPopup>
98147 </Menu>
99148 );
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx created+186
......@@ -0,0 +1,186 @@
1import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts";
2import "../../index.css";
3
4import { page } from "vitest/browser";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
9import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker";
10import { CodexTraitsMenuContent } from "./CodexTraitsPicker";
11import { useComposerDraftStore } from "../../composerDraftStore";
12
13async function mountMenu(props?: {
14 model?: string;
15 prompt?: string;
16 provider?: "codex" | "claudeAgent";
17 modelOptions?: ProviderModelOptions | null;
18}) {
19 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
20 const provider = props?.provider ?? "claudeAgent";
21 const draftsByThreadId = {} as ReturnType<
22 typeof useComposerDraftStore.getState
23 >["draftsByThreadId"];
24 draftsByThreadId[threadId] = {
25 prompt: props?.prompt ?? "",
26 images: [],
27 nonPersistedImageIds: [],
28 persistedAttachments: [],
29 terminalContexts: [],
30 provider,
31 model: props?.model ?? "claude-opus-4-6",
32 modelOptions: props?.modelOptions ?? null,
33 runtimeMode: null,
34 interactionMode: null,
35 };
36 useComposerDraftStore.setState({
37 draftsByThreadId,
38 draftThreadsByThreadId: {},
39 projectDraftThreadIdByProjectId: {},
40 });
41 const host = document.createElement("div");
42 document.body.append(host);
43 const onPromptChange = vi.fn();
44 const screen = await render(
45 <CompactComposerControlsMenu
46 activePlan={false}
47 interactionMode="default"
48 planSidebarOpen={false}
49 runtimeMode="approval-required"
50 traitsMenuContent={
51 provider === "codex" ? (
52 <CodexTraitsMenuContent threadId={threadId} />
53 ) : (
54 <ClaudeTraitsMenuContent
55 threadId={threadId}
56 model={props?.model ?? "claude-opus-4-6"}
57 onPromptChange={onPromptChange}
58 />
59 )
60 }
61 onToggleInteractionMode={vi.fn()}
62 onTogglePlanSidebar={vi.fn()}
63 onToggleRuntimeMode={vi.fn()}
64 />,
65 { container: host },
66 );
67
68 return {
69 cleanup: async () => {
70 await screen.unmount();
71 host.remove();
72 },
73 };
74}
75
76describe("CompactComposerControlsMenu", () => {
77 afterEach(() => {
78 document.body.innerHTML = "";
79 useComposerDraftStore.setState({
80 draftsByThreadId: {},
81 draftThreadsByThreadId: {},
82 projectDraftThreadIdByProjectId: {},
83 });
84 });
85
86 it("shows fast mode controls for Opus", async () => {
87 const mounted = await mountMenu();
88
89 try {
90 await page.getByLabelText("More composer controls").click();
91
92 await vi.waitFor(() => {
93 const text = document.body.textContent ?? "";
94 expect(text).toContain("Fast Mode");
95 expect(text).toContain("off");
96 expect(text).toContain("on");
97 });
98 } finally {
99 await mounted.cleanup();
100 }
101 });
102
103 it("hides fast mode controls for non-Opus Claude models", async () => {
104 const mounted = await mountMenu({ model: "claude-sonnet-4-6" });
105
106 try {
107 await page.getByLabelText("More composer controls").click();
108
109 await vi.waitFor(() => {
110 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
111 });
112 } finally {
113 await mounted.cleanup();
114 }
115 });
116
117 it("shows only the provided effort options", async () => {
118 const mounted = await mountMenu({
119 model: "claude-sonnet-4-6",
120 });
121
122 try {
123 await page.getByLabelText("More composer controls").click();
124
125 await vi.waitFor(() => {
126 const text = document.body.textContent ?? "";
127 expect(text).toContain("Low");
128 expect(text).toContain("Medium");
129 expect(text).toContain("High");
130 expect(text).not.toContain("Max");
131 expect(text).toContain("Ultrathink");
132 });
133 } finally {
134 await mounted.cleanup();
135 }
136 });
137
138 it("shows a Claude thinking on/off section for Haiku", async () => {
139 const mounted = await mountMenu({
140 model: "claude-haiku-4-5",
141 modelOptions: {
142 claudeAgent: {
143 thinking: true,
144 },
145 },
146 });
147
148 try {
149 await page.getByLabelText("More composer controls").click();
150
151 await vi.waitFor(() => {
152 const text = document.body.textContent ?? "";
153 expect(text).toContain("Thinking");
154 expect(text).toContain("On (default)");
155 expect(text).toContain("Off");
156 });
157 } finally {
158 await mounted.cleanup();
159 }
160 });
161
162 it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => {
163 const mounted = await mountMenu({
164 model: "claude-opus-4-6",
165 prompt: "Ultrathink:\nInvestigate this",
166 modelOptions: {
167 claudeAgent: {
168 effort: "high",
169 },
170 },
171 });
172
173 try {
174 await page.getByLabelText("More composer controls").click();
175
176 await vi.waitFor(() => {
177 const text = document.body.textContent ?? "";
178 expect(text).toContain("Effort");
179 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
180 expect(text).not.toContain("Fallback Effort");
181 });
182 } finally {
183 await mounted.cleanup();
184 }
185 });
186});
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+27-82
......@@ -1,16 +1,9 @@
1import {
2 type CodexReasoningEffort,
3 type ProviderKind,
4 RuntimeMode,
5 ProviderInteractionMode,
6} from "@t3tools/contracts";
7import { getDefaultReasoningEffort } from "@t3tools/shared/model";
8import { memo } from "react";
1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode } from "react";
93import { EllipsisIcon, ListTodoIcon } from "lucide-react";
104import { Button } from "../ui/button";
115import {
126 Menu,
13 MenuGroup,
147 MenuItem,
158 MenuPopup,
169 MenuRadioGroup,
......@@ -24,24 +17,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
2417 interactionMode: ProviderInteractionMode;
2518 planSidebarOpen: boolean;
2619 runtimeMode: RuntimeMode;
27 selectedEffort: CodexReasoningEffort | null;
28 selectedProvider: ProviderKind;
29 selectedCodexFastModeEnabled: boolean;
30 reasoningOptions: ReadonlyArray<CodexReasoningEffort>;
31 onEffortSelect: (effort: CodexReasoningEffort) => void;
32 onCodexFastModeChange: (enabled: boolean) => void;
20 traitsMenuContent?: ReactNode;
3321 onToggleInteractionMode: () => void;
3422 onTogglePlanSidebar: () => void;
3523 onToggleRuntimeMode: () => void;
3624}) {
37 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
38 const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
39 low: "Low",
40 medium: "Medium",
41 high: "High",
42 xhigh: "Extra High",
43 };
44
4525 return (
4626 <Menu>
4727 <MenuTrigger
......@@ -57,70 +37,35 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
5737 <EllipsisIcon aria-hidden="true" className="size-4" />
5838 </MenuTrigger>
5939 <MenuPopup align="start">
60 {props.selectedProvider === "codex" && props.selectedEffort != null ? (
40 {props.traitsMenuContent ? (
6141 <>
62 <MenuGroup>
63 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
64 <MenuRadioGroup
65 value={props.selectedEffort}
66 onValueChange={(value) => {
67 if (!value) return;
68 const nextEffort = props.reasoningOptions.find((option) => option === value);
69 if (!nextEffort) return;
70 props.onEffortSelect(nextEffort);
71 }}
72 >
73 {props.reasoningOptions.map((effort) => (
74 <MenuRadioItem key={effort} value={effort}>
75 {reasoningLabelByOption[effort]}
76 {effort === defaultReasoningEffort ? " (default)" : ""}
77 </MenuRadioItem>
78 ))}
79 </MenuRadioGroup>
80 </MenuGroup>
81 <MenuDivider />
82 <MenuGroup>
83 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
84 <MenuRadioGroup
85 value={props.selectedCodexFastModeEnabled ? "on" : "off"}
86 onValueChange={(value) => {
87 props.onCodexFastModeChange(value === "on");
88 }}
89 >
90 <MenuRadioItem value="off">off</MenuRadioItem>
91 <MenuRadioItem value="on">on</MenuRadioItem>
92 </MenuRadioGroup>
93 </MenuGroup>
42 {props.traitsMenuContent}
9443 <MenuDivider />
9544 </>
9645 ) : null}
97 <MenuGroup>
98 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
99 <MenuRadioGroup
100 value={props.interactionMode}
101 onValueChange={(value) => {
102 if (!value || value === props.interactionMode) return;
103 props.onToggleInteractionMode();
104 }}
105 >
106 <MenuRadioItem value="default">Chat</MenuRadioItem>
107 <MenuRadioItem value="plan">Plan</MenuRadioItem>
108 </MenuRadioGroup>
109 </MenuGroup>
46 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
47 <MenuRadioGroup
48 value={props.interactionMode}
49 onValueChange={(value) => {
50 if (!value || value === props.interactionMode) return;
51 props.onToggleInteractionMode();
52 }}
53 >
54 <MenuRadioItem value="default">Chat</MenuRadioItem>
55 <MenuRadioItem value="plan">Plan</MenuRadioItem>
56 </MenuRadioGroup>
11057 <MenuDivider />
111 <MenuGroup>
112 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>
113 <MenuRadioGroup
114 value={props.runtimeMode}
115 onValueChange={(value) => {
116 if (!value || value === props.runtimeMode) return;
117 props.onToggleRuntimeMode();
118 }}
119 >
120 <MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
121 <MenuRadioItem value="full-access">Full access</MenuRadioItem>
122 </MenuRadioGroup>
123 </MenuGroup>
58 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>
59 <MenuRadioGroup
60 value={props.runtimeMode}
61 onValueChange={(value) => {
62 if (!value || value === props.runtimeMode) return;
63 props.onToggleRuntimeMode();
64 }}
65 >
66 <MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
67 <MenuRadioItem value="full-access">Full access</MenuRadioItem>
68 </MenuRadioGroup>
12469 {props.activePlan ? (
12570 <>
12671 <MenuDivider />
apps/web/src/components/chat/ProviderHealthBanner.tsx+10-5
......@@ -12,18 +12,23 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({
1212 return null;
1313 }
1414
15 const providerLabel =
16 status.provider === "codex"
17 ? "Codex"
18 : status.provider === "claudeAgent"
19 ? "Claude"
20 : status.provider;
1521 const defaultMessage =
1622 status.status === "error"
17 ? `${status.provider} provider is unavailable.`
18 : `${status.provider} provider has limited availability.`;
23 ? `${providerLabel} provider is unavailable.`
24 : `${providerLabel} provider has limited availability.`;
25 const title = providerLabel === "Codex" ? "Codex provider status" : `${providerLabel} status`;
1926
2027 return (
2128 <div className="pt-3 mx-auto max-w-3xl">
2229 <Alert variant={status.status === "error" ? "error" : "warning"}>
2330 <CircleAlertIcon />
24 <AlertTitle>
25 {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`}
26 </AlertTitle>
31 <AlertTitle>{title}</AlertTitle>
2732 <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}>
2833 {status.message ?? defaultMessage}
2934 </AlertDescription>
apps/web/src/components/chat/ProviderModelPicker.browser.tsx created+94
......@@ -0,0 +1,94 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { page } from "vitest/browser";
3import { afterEach, describe, expect, it, vi } from "vitest";
4import { render } from "vitest-browser-react";
5
6import { ProviderModelPicker } from "./ProviderModelPicker";
7
8const MODEL_OPTIONS_BY_PROVIDER = {
9 claudeAgent: [
10 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
11 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
12 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
13 ],
14 codex: [
15 { slug: "gpt-5-codex", name: "GPT-5 Codex" },
16 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
17 ],
18} as const satisfies Record<ProviderKind, ReadonlyArray<{ slug: ModelSlug; name: string }>>;
19
20async function mountPicker(props: {
21 provider: ProviderKind;
22 model: ModelSlug;
23 lockedProvider: ProviderKind | null;
24}) {
25 const host = document.createElement("div");
26 document.body.append(host);
27 const onProviderModelChange = vi.fn();
28 const screen = await render(
29 <ProviderModelPicker
30 provider={props.provider}
31 model={props.model}
32 lockedProvider={props.lockedProvider}
33 modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
34 onProviderModelChange={onProviderModelChange}
35 />,
36 { container: host },
37 );
38
39 return {
40 onProviderModelChange,
41 cleanup: async () => {
42 await screen.unmount();
43 host.remove();
44 },
45 };
46}
47
48describe("ProviderModelPicker", () => {
49 afterEach(() => {
50 document.body.innerHTML = "";
51 });
52
53 it("shows provider submenus when provider switching is allowed", async () => {
54 const mounted = await mountPicker({
55 provider: "claudeAgent",
56 model: "claude-opus-4-6",
57 lockedProvider: null,
58 });
59
60 try {
61 await page.getByRole("button").click();
62
63 await vi.waitFor(() => {
64 const text = document.body.textContent ?? "";
65 expect(text).toContain("Codex");
66 expect(text).toContain("Claude");
67 expect(text).not.toContain("Claude Sonnet 4.6");
68 });
69 } finally {
70 await mounted.cleanup();
71 }
72 });
73
74 it("shows models directly when the provider is locked mid-thread", async () => {
75 const mounted = await mountPicker({
76 provider: "claudeAgent",
77 model: "claude-opus-4-6",
78 lockedProvider: "claudeAgent",
79 });
80
81 try {
82 await page.getByRole("button").click();
83
84 await vi.waitFor(() => {
85 const text = document.body.textContent ?? "";
86 expect(text).toContain("Claude Sonnet 4.6");
87 expect(text).toContain("Claude Haiku 4.5");
88 expect(text).not.toContain("Codex");
89 });
90 } finally {
91 await mounted.cleanup();
92 }
93 });
94});
apps/web/src/components/chat/ProviderModelPicker.tsx+119-82
......@@ -25,7 +25,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o
2525 label: string;
2626 available: true;
2727} {
28 return option.available && option.value !== "claudeCode";
28 return option.available;
2929}
3030
3131function resolveModelForProviderPicker(
......@@ -63,7 +63,7 @@ function resolveModelForProviderPicker(
6363
6464const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
6565 codex: OpenAI,
66 claudeCode: ClaudeAI,
66 claudeAgent: ClaudeAI,
6767 cursor: CursorIcon,
6868};
6969
......@@ -74,20 +74,41 @@ const COMING_SOON_PROVIDER_OPTIONS = [
7474 { id: "gemini", label: "Gemini", icon: Gemini },
7575] as const;
7676
77function providerIconClassName(
78 provider: ProviderKind | ProviderPickerKind,
79 fallbackClassName: string,
80): string {
81 return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName;
82}
83
7784export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
7885 provider: ProviderKind;
7986 model: ModelSlug;
8087 lockedProvider: ProviderKind | null;
8188 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
89 ultrathinkActive?: boolean;
8290 compact?: boolean;
8391 disabled?: boolean;
8492 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
8593}) {
8694 const [isMenuOpen, setIsMenuOpen] = useState(false);
87 const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
95 const activeProvider = props.lockedProvider ?? props.provider;
96 const selectedProviderOptions = props.modelOptionsByProvider[activeProvider];
8897 const selectedModelLabel =
8998 selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
90 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
99 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider];
100 const handleModelChange = (provider: ProviderKind, value: string) => {
101 if (props.disabled) return;
102 if (!value) return;
103 const resolvedModel = resolveModelForProviderPicker(
104 provider,
105 value,
106 props.modelOptionsByProvider[provider],
107 );
108 if (!resolvedModel) return;
109 props.onProviderModelChange(provider, resolvedModel);
110 setIsMenuOpen(false);
111 };
91112
92113 return (
93114 <Menu
......@@ -119,90 +140,106 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
119140 props.compact ? "max-w-36" : undefined,
120141 )}
121142 >
122 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
143 <ProviderIcon
144 aria-hidden="true"
145 className={cn(
146 "size-4 shrink-0",
147 providerIconClassName(activeProvider, "text-muted-foreground/70"),
148 activeProvider === "claudeAgent" && props.ultrathinkActive
149 ? "ultrathink-chroma"
150 : undefined,
151 )}
152 />
123153 <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span>
124154 <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
125155 </span>
126156 </MenuTrigger>
127157 <MenuPopup align="start">
128 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
129 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
130 const isDisabledByProviderLock =
131 props.lockedProvider !== null && props.lockedProvider !== option.value;
132 return (
133 <MenuSub key={option.value}>
134 <MenuSubTrigger disabled={isDisabledByProviderLock}>
135 <OptionIcon
136 aria-hidden="true"
137 className="size-4 shrink-0 text-muted-foreground/85"
138 />
139 {option.label}
140 </MenuSubTrigger>
141 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">
142 <MenuGroup>
143 <MenuRadioGroup
144 value={props.provider === option.value ? props.model : ""}
145 onValueChange={(value) => {
146 if (props.disabled) return;
147 if (isDisabledByProviderLock) return;
148 if (!value) return;
149 const resolvedModel = resolveModelForProviderPicker(
150 option.value,
151 value,
152 props.modelOptionsByProvider[option.value],
153 );
154 if (!resolvedModel) return;
155 props.onProviderModelChange(option.value, resolvedModel);
156 setIsMenuOpen(false);
157 }}
158 >
159 {props.modelOptionsByProvider[option.value].map((modelOption) => (
160 <MenuRadioItem
161 key={`${option.value}:${modelOption.slug}`}
162 value={modelOption.slug}
163 onClick={() => setIsMenuOpen(false)}
158 {props.lockedProvider !== null ? (
159 <MenuGroup>
160 <MenuRadioGroup
161 value={props.model}
162 onValueChange={(value) => handleModelChange(props.lockedProvider!, value)}
163 >
164 {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => (
165 <MenuRadioItem
166 key={`${props.lockedProvider}:${modelOption.slug}`}
167 value={modelOption.slug}
168 onClick={() => setIsMenuOpen(false)}
169 >
170 {modelOption.name}
171 </MenuRadioItem>
172 ))}
173 </MenuRadioGroup>
174 </MenuGroup>
175 ) : (
176 <>
177 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
178 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
179 return (
180 <MenuSub key={option.value}>
181 <MenuSubTrigger>
182 <OptionIcon
183 aria-hidden="true"
184 className={cn(
185 "size-4 shrink-0",
186 providerIconClassName(option.value, "text-muted-foreground/85"),
187 )}
188 />
189 {option.label}
190 </MenuSubTrigger>
191 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">
192 <MenuGroup>
193 <MenuRadioGroup
194 value={props.provider === option.value ? props.model : ""}
195 onValueChange={(value) => handleModelChange(option.value, value)}
164196 >
165 {modelOption.name}
166 </MenuRadioItem>
167 ))}
168 </MenuRadioGroup>
169 </MenuGroup>
170 </MenuSubPopup>
171 </MenuSub>
172 );
173 })}
174 {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
175 {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
176 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
177 return (
178 <MenuItem key={option.value} disabled>
179 <OptionIcon
180 aria-hidden="true"
181 className={cn(
182 "size-4 shrink-0 opacity-80",
183 option.value === "claudeCode" ? "" : "text-muted-foreground/85",
184 )}
185 />
186 <span>{option.label}</span>
187 <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
188 Coming soon
189 </span>
190 </MenuItem>
191 );
192 })}
193 {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />}
194 {COMING_SOON_PROVIDER_OPTIONS.map((option) => {
195 const OptionIcon = option.icon;
196 return (
197 <MenuItem key={option.id} disabled>
198 <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" />
199 <span>{option.label}</span>
200 <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
201 Coming soon
202 </span>
203 </MenuItem>
204 );
205 })}
197 {props.modelOptionsByProvider[option.value].map((modelOption) => (
198 <MenuRadioItem
199 key={`${option.value}:${modelOption.slug}`}
200 value={modelOption.slug}
201 onClick={() => setIsMenuOpen(false)}
202 >
203 {modelOption.name}
204 </MenuRadioItem>
205 ))}
206 </MenuRadioGroup>
207 </MenuGroup>
208 </MenuSubPopup>
209 </MenuSub>
210 );
211 })}
212 {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
213 {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
214 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
215 return (
216 <MenuItem key={option.value} disabled>
217 <OptionIcon
218 aria-hidden="true"
219 className="size-4 shrink-0 text-muted-foreground/85 opacity-80"
220 />
221 <span>{option.label}</span>
222 <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
223 Coming soon
224 </span>
225 </MenuItem>
226 );
227 })}
228 {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />}
229 {COMING_SOON_PROVIDER_OPTIONS.map((option) => {
230 const OptionIcon = option.icon;
231 return (
232 <MenuItem key={option.id} disabled>
233 <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" />
234 <span>{option.label}</span>
235 <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
236 Coming soon
237 </span>
238 </MenuItem>
239 );
240 })}
241 </>
242 )}
206243 </MenuPopup>
207244 </Menu>
208245 );
apps/web/src/composerDraftStore.test.ts+122-9
......@@ -1,16 +1,19 @@
1import * as Schema from "effect/Schema";
12import { ProjectId, ThreadId } from "@t3tools/contracts";
23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
45import {
6 COMPOSER_DRAFT_STORAGE_KEY,
57 type ComposerImageAttachment,
6 createDebouncedStorage,
78 useComposerDraftStore,
89} from "./composerDraftStore";
10import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
911import {
1012 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
1113 insertInlineTerminalContextPlaceholder,
1214 type TerminalContextDraft,
1315} from "./lib/terminalContext";
16import { createDebouncedStorage } from "./lib/storage";
1417
1518function makeImage(input: {
1619 id: string;
......@@ -183,6 +186,63 @@ describe("composerDraftStore clearComposerContent", () => {
183186 });
184187});
185188
189describe("composerDraftStore syncPersistedAttachments", () => {
190 const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
191
192 beforeEach(() => {
193 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
194 useComposerDraftStore.setState({
195 draftsByThreadId: {},
196 draftThreadsByThreadId: {},
197 projectDraftThreadIdByProjectId: {},
198 });
199 });
200
201 afterEach(() => {
202 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
203 });
204
205 it("treats malformed persisted draft storage as empty", async () => {
206 const image = makeImage({
207 id: "img-persisted",
208 previewUrl: "blob:persisted",
209 });
210 useComposerDraftStore.getState().addImage(threadId, image);
211 setLocalStorageItem(
212 COMPOSER_DRAFT_STORAGE_KEY,
213 {
214 version: 2,
215 state: {
216 draftsByThreadId: {
217 [threadId]: {
218 attachments: "not-an-array",
219 },
220 },
221 },
222 },
223 Schema.Unknown,
224 );
225
226 useComposerDraftStore.getState().syncPersistedAttachments(threadId, [
227 {
228 id: image.id,
229 name: image.name,
230 mimeType: image.mimeType,
231 sizeBytes: image.sizeBytes,
232 dataUrl: image.previewUrl,
233 },
234 ]);
235 await Promise.resolve();
236
237 expect(
238 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments,
239 ).toEqual([]);
240 expect(
241 useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds,
242 ).toEqual([image.id]);
243 });
244});
245
186246describe("composerDraftStore terminal contexts", () => {
187247 const threadId = ThreadId.makeUnsafe("thread-dedupe");
188248
......@@ -323,6 +383,37 @@ describe("composerDraftStore terminal contexts", () => {
323383 },
324384 ]);
325385 });
386
387 it("sanitizes malformed persisted drafts during merge", () => {
388 const persistApi = useComposerDraftStore.persist as unknown as {
389 getOptions: () => {
390 merge: (
391 persistedState: unknown,
392 currentState: ReturnType<typeof useComposerDraftStore.getState>,
393 ) => ReturnType<typeof useComposerDraftStore.getState>;
394 };
395 };
396 const mergedState = persistApi.getOptions().merge(
397 {
398 draftsByThreadId: {
399 [threadId]: {
400 prompt: "",
401 attachments: "not-an-array",
402 terminalContexts: "not-an-array",
403 provider: "bogus-provider",
404 modelOptions: "not-an-object",
405 },
406 },
407 draftThreadsByThreadId: "not-an-object",
408 projectDraftThreadIdByProjectId: "not-an-object",
409 },
410 useComposerDraftStore.getInitialState(),
411 );
412
413 expect(mergedState.draftsByThreadId[threadId]).toBeUndefined();
414 expect(mergedState.draftThreadsByThreadId).toEqual({});
415 expect(mergedState.projectDraftThreadIdByProjectId).toEqual({});
416 });
326417});
327418
328419describe("composerDraftStore project draft thread mapping", () => {
......@@ -504,8 +595,8 @@ describe("composerDraftStore project draft thread mapping", () => {
504595 });
505596});
506597
507describe("composerDraftStore codex fast mode", () => {
508 const threadId = ThreadId.makeUnsafe("thread-service-tier");
598describe("composerDraftStore modelOptions", () => {
599 const threadId = ThreadId.makeUnsafe("thread-model-options");
509600
510601 beforeEach(() => {
511602 useComposerDraftStore.setState({
......@@ -515,17 +606,39 @@ describe("composerDraftStore codex fast mode", () => {
515606 });
516607 });
517608
518 it("stores codex fast mode in the draft", () => {
609 it("stores provider-scoped model options in the draft", () => {
519610 const store = useComposerDraftStore.getState();
520 store.setCodexFastMode(threadId, true);
611 store.setModelOptions(threadId, {
612 codex: {
613 reasoningEffort: "xhigh",
614 fastMode: true,
615 },
616 claudeAgent: {
617 thinking: false,
618 },
619 });
521620
522 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true);
621 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
622 codex: {
623 reasoningEffort: "xhigh",
624 fastMode: true,
625 },
626 claudeAgent: {
627 thinking: false,
628 },
629 });
523630 });
524631
525 it("clears codex fast mode when reset to the default", () => {
632 it("drops default-only model options from the draft", () => {
526633 const store = useComposerDraftStore.getState();
527 store.setCodexFastMode(threadId, true);
528 store.setCodexFastMode(threadId, false);
634 store.setModelOptions(threadId, {
635 codex: {
636 reasoningEffort: "high",
637 },
638 claudeAgent: {
639 thinking: true,
640 },
641 });
529642
530643 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
531644 });
apps/web/src/composerDraftStore.ts+331-258
......@@ -1,60 +1,40 @@
11import {
2 CODEX_REASONING_EFFORT_OPTIONS,
3 type ClaudeCodeEffort,
4 type CodexReasoningEffort,
25 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
36 ProjectId,
4 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
7 ProviderInteractionMode,
8 ProviderKind,
9 ProviderModelOptions,
10 RuntimeMode,
511 ThreadId,
6 type CodexReasoningEffort,
7 type ProviderKind,
8 type ProviderInteractionMode,
9 type RuntimeMode,
1012} from "@t3tools/contracts";
13import * as Schema from "effect/Schema";
14import { DeepMutable } from "effect/Types";
1115import { normalizeModelSlug } from "@t3tools/shared/model";
16import { getLocalStorageItem } from "./hooks/useLocalStorage";
1217import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
1318import {
1419 type TerminalContextDraft,
1520 ensureInlineTerminalContextPlaceholders,
1621 normalizeTerminalContextText,
1722} from "./lib/terminalContext";
18import { Debouncer } from "@tanstack/react-pacer";
1923import { create } from "zustand";
20import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
24import { createJSONStorage, persist } from "zustand/middleware";
25import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
2126
2227export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
23export type DraftThreadEnvMode = "local" | "worktree";
28const COMPOSER_DRAFT_STORAGE_VERSION = 2;
29const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
30export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
2431
2532const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
2633
27interface DebouncedStorage extends StateStorage {
28 flush: () => void;
29}
30
31export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage {
32 const debouncedSetItem = new Debouncer(
33 (name: string, value: string) => {
34 baseStorage.setItem(name, value);
35 },
36 { wait: COMPOSER_PERSIST_DEBOUNCE_MS },
37 );
38
39 return {
40 getItem: (name) => baseStorage.getItem(name),
41 setItem: (name, value) => {
42 debouncedSetItem.maybeExecute(name, value);
43 },
44 removeItem: (name) => {
45 debouncedSetItem.cancel();
46 baseStorage.removeItem(name);
47 },
48 flush: () => {
49 debouncedSetItem.flush();
50 },
51 };
52}
53
54const composerDebouncedStorage: DebouncedStorage =
55 typeof localStorage !== "undefined"
56 ? createDebouncedStorage(localStorage)
57 : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} };
34const composerDebouncedStorage = createDebouncedStorage(
35 typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(),
36 COMPOSER_PERSIST_DEBOUNCE_MS,
37);
5838
5939// Flush pending composer draft writes before page unload to prevent data loss.
6040if (typeof window !== "undefined") {
......@@ -63,57 +43,74 @@ if (typeof window !== "undefined") {
6343 });
6444}
6545
66export interface PersistedComposerImageAttachment {
67 id: string;
68 name: string;
69 mimeType: string;
70 sizeBytes: number;
71 dataUrl: string;
72}
46export const PersistedComposerImageAttachment = Schema.Struct({
47 id: Schema.String,
48 name: Schema.String,
49 mimeType: Schema.String,
50 sizeBytes: Schema.Number,
51 dataUrl: Schema.String,
52});
53export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type;
7354
7455export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
7556 previewUrl: string;
7657 file: File;
7758}
7859
79interface PersistedTerminalContextDraft {
80 id: string;
81 threadId: ThreadId;
82 createdAt: string;
83 terminalId: string;
84 terminalLabel: string;
85 lineStart: number;
86 lineEnd: number;
87}
60const PersistedTerminalContextDraft = Schema.Struct({
61 id: Schema.String,
62 threadId: ThreadId,
63 createdAt: Schema.String,
64 terminalId: Schema.String,
65 terminalLabel: Schema.String,
66 lineStart: Schema.Number,
67 lineEnd: Schema.Number,
68});
69type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type;
8870
89interface PersistedComposerThreadDraftState {
90 prompt: string;
91 attachments: PersistedComposerImageAttachment[];
92 terminalContexts?: PersistedTerminalContextDraft[];
93 provider?: ProviderKind | null;
94 model?: string | null;
95 runtimeMode?: RuntimeMode | null;
96 interactionMode?: ProviderInteractionMode | null;
97 effort?: CodexReasoningEffort | null;
98 codexFastMode?: boolean | null;
99 serviceTier?: string | null;
100}
71const PersistedComposerThreadDraftState = Schema.Struct({
72 prompt: Schema.String,
73 attachments: Schema.Array(PersistedComposerImageAttachment),
74 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
75 provider: Schema.optionalKey(ProviderKind),
76 model: Schema.optionalKey(Schema.String),
77 modelOptions: Schema.optionalKey(ProviderModelOptions),
78 runtimeMode: Schema.optionalKey(RuntimeMode),
79 interactionMode: Schema.optionalKey(ProviderInteractionMode),
80});
81type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type;
10182
102interface PersistedDraftThreadState {
103 projectId: ProjectId;
104 createdAt: string;
105 runtimeMode: RuntimeMode;
106 interactionMode: ProviderInteractionMode;
107 branch: string | null;
108 worktreePath: string | null;
109 envMode: DraftThreadEnvMode;
110}
83const LegacyCodexFields = Schema.Struct({
84 effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)),
85 codexFastMode: Schema.optionalKey(Schema.Boolean),
86 serviceTier: Schema.optionalKey(Schema.String),
87});
88type LegacyCodexFields = typeof LegacyCodexFields.Type;
11189
112interface PersistedComposerDraftStoreState {
113 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
114 draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>;
115 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
116}
90type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields;
91
92const PersistedDraftThreadState = Schema.Struct({
93 projectId: ProjectId,
94 createdAt: Schema.String,
95 runtimeMode: RuntimeMode,
96 interactionMode: ProviderInteractionMode,
97 branch: Schema.NullOr(Schema.String),
98 worktreePath: Schema.NullOr(Schema.String),
99 envMode: DraftThreadEnvModeSchema,
100});
101type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type;
102
103const PersistedComposerDraftStoreState = Schema.Struct({
104 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
105 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
106 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
107});
108type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
109
110const PersistedComposerDraftStoreStorage = Schema.Struct({
111 version: Schema.Number,
112 state: PersistedComposerDraftStoreState,
113});
117114
118115interface ComposerThreadDraftState {
119116 prompt: string;
......@@ -123,10 +120,9 @@ interface ComposerThreadDraftState {
123120 terminalContexts: TerminalContextDraft[];
124121 provider: ProviderKind | null;
125122 model: string | null;
123 modelOptions: ProviderModelOptions | null;
126124 runtimeMode: RuntimeMode | null;
127125 interactionMode: ProviderInteractionMode | null;
128 effort: CodexReasoningEffort | null;
129 codexFastMode: boolean;
130126}
131127
132128export interface DraftThreadState {
......@@ -180,13 +176,15 @@ interface ComposerDraftStoreState {
180176 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
181177 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
182178 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
179 setModelOptions: (
180 threadId: ThreadId,
181 modelOptions: ProviderModelOptions | null | undefined,
182 ) => void;
183183 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
184184 setInteractionMode: (
185185 threadId: ThreadId,
186186 interactionMode: ProviderInteractionMode | null | undefined,
187187 ) => void;
188 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;
189 setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void;
190188 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
191189 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
192190 removeImage: (threadId: ThreadId, imageId: string) => void;
......@@ -222,8 +220,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
222220Object.freeze(EMPTY_IMAGES);
223221Object.freeze(EMPTY_IDS);
224222Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
225Object.freeze(EMPTY_TERMINAL_CONTEXTS);
226const EMPTY_THREAD_DRAFT = Object.freeze({
223const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
227224 prompt: "",
228225 images: EMPTY_IMAGES,
229226 nonPersistedImageIds: EMPTY_IDS,
......@@ -231,15 +228,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
231228 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
232229 provider: null,
233230 model: null,
231 modelOptions: null,
234232 runtimeMode: null,
235233 interactionMode: null,
236 effort: null,
237 codexFastMode: false,
238}) as ComposerThreadDraftState;
239
240const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>(
241 REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex,
242);
234});
243235
244236function createEmptyThreadDraft(): ComposerThreadDraftState {
245237 return {
......@@ -250,10 +242,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
250242 terminalContexts: [],
251243 provider: null,
252244 model: null,
245 modelOptions: null,
253246 runtimeMode: null,
254247 interactionMode: null,
255 effort: null,
256 codexFastMode: false,
257248 };
258249}
259250
......@@ -322,15 +313,88 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
322313 draft.terminalContexts.length === 0 &&
323314 draft.provider === null &&
324315 draft.model === null &&
316 draft.modelOptions === null &&
325317 draft.runtimeMode === null &&
326 draft.interactionMode === null &&
327 draft.effort === null &&
328 draft.codexFastMode === false
318 draft.interactionMode === null
329319 );
330320}
331321
332322function normalizeProviderKind(value: unknown): ProviderKind | null {
333 return value === "codex" ? value : null;
323 return value === "codex" || value === "claudeAgent" ? value : null;
324}
325
326function normalizeProviderModelOptions(
327 value: unknown,
328 provider?: ProviderKind | null,
329 legacy?: LegacyCodexFields,
330): ProviderModelOptions | null {
331 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
332 const codexCandidate =
333 candidate?.codex && typeof candidate.codex === "object"
334 ? (candidate.codex as Record<string, unknown>)
335 : null;
336 const claudeCandidate =
337 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"
338 ? (candidate.claudeAgent as Record<string, unknown>)
339 : null;
340
341 const codexReasoningEffort: CodexReasoningEffort | undefined =
342 codexCandidate?.reasoningEffort === "low" ||
343 codexCandidate?.reasoningEffort === "medium" ||
344 codexCandidate?.reasoningEffort === "high" ||
345 codexCandidate?.reasoningEffort === "xhigh"
346 ? codexCandidate.reasoningEffort
347 : provider === "codex" &&
348 (legacy?.effort === "low" ||
349 legacy?.effort === "medium" ||
350 legacy?.effort === "high" ||
351 legacy?.effort === "xhigh")
352 ? legacy.effort
353 : undefined;
354 const codexFastMode =
355 codexCandidate?.fastMode === true ||
356 (provider === "codex" && legacy?.codexFastMode === true) ||
357 (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast");
358 const codex =
359 codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex
360 ? {
361 reasoningEffort: codexReasoningEffort,
362 ...(codexFastMode ? { fastMode: true } : {}),
363 }
364 : codexFastMode
365 ? { fastMode: true }
366 : undefined;
367
368 const claudeThinking = claudeCandidate?.thinking === false ? false : undefined;
369 const claudeEffort: ClaudeCodeEffort | undefined =
370 claudeCandidate?.effort === "low" ||
371 claudeCandidate?.effort === "medium" ||
372 claudeCandidate?.effort === "high" ||
373 claudeCandidate?.effort === "max" ||
374 claudeCandidate?.effort === "ultrathink"
375 ? claudeCandidate.effort
376 : undefined;
377 const claudeFastMode = claudeCandidate?.fastMode === true;
378 const claude =
379 claudeThinking === false ||
380 (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) ||
381 claudeFastMode
382 ? {
383 ...(claudeThinking === false ? { thinking: false } : {}),
384 ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent
385 ? { effort: claudeEffort }
386 : {}),
387 ...(claudeFastMode ? { fastMode: true } : {}),
388 }
389 : undefined;
390
391 if (!codex && !claude) {
392 return null;
393 }
394 return {
395 ...(codex ? { codex } : {}),
396 ...(claude ? { claudeAgent: claude } : {}),
397 };
334398}
335399
336400function revokeObjectPreviewUrl(previewUrl: string): void {
......@@ -429,15 +493,14 @@ function normalizeDraftThreadEnvMode(
429493 return fallbackWorktreePath ? "worktree" : "local";
430494}
431495
432function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
433 if (!value || typeof value !== "object") {
434 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
435 }
436 const candidate = value as Record<string, unknown>;
437 const rawDraftMap = candidate.draftsByThreadId;
438 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
439 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
440 const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {};
496function normalizePersistedDraftThreads(
497 rawDraftThreadsByThreadId: unknown,
498 rawProjectDraftThreadIdByProjectId: unknown,
499): Pick<
500 PersistedComposerDraftStoreState,
501 "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
502> {
503 const draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState> = {};
441504 if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
442505 for (const [threadId, rawDraftThread] of Object.entries(
443506 rawDraftThreadsByThreadId as Record<string, unknown>,
......@@ -479,8 +542,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
479542 };
480543 }
481544 }
482 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
483 {};
545
546 const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {};
484547 if (
485548 rawProjectDraftThreadIdByProjectId &&
486549 typeof rawProjectDraftThreadIdByProjectId === "object"
......@@ -514,10 +577,23 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
514577 }
515578 }
516579 }
580
581 return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };
582}
583
584function normalizePersistedDraftsByThreadId(
585 rawDraftMap: unknown,
586 resolveModelOptions: (
587 draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState,
588 provider: ProviderKind | null,
589 ) => ProviderModelOptions | null,
590): PersistedComposerDraftStoreState["draftsByThreadId"] {
517591 if (!rawDraftMap || typeof rawDraftMap !== "object") {
518 return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
592 return {};
519593 }
520 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
594
595 const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> =
596 {};
521597 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
522598 if (typeof threadId !== "string" || threadId.length === 0) {
523599 continue;
......@@ -525,7 +601,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
525601 if (!draftValue || typeof draftValue !== "object") {
526602 continue;
527603 }
528 const draftCandidate = draftValue as Record<string, unknown>;
604 const draftCandidate = draftValue as
605 | PersistedComposerThreadDraftState
606 | LegacyPersistedCodexThreadDraftState;
529607 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
530608 const attachments = Array.isArray(draftCandidate.attachments)
531609 ? draftCandidate.attachments.flatMap((entry) => {
......@@ -553,15 +631,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
553631 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
554632 ? draftCandidate.interactionMode
555633 : null;
556 const effortCandidate =
557 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
558 const effort =
559 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)
560 ? (effortCandidate as CodexReasoningEffort)
561 : null;
562 const codexFastMode =
563 draftCandidate.codexFastMode === true ||
564 (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast");
634 const modelOptions = resolveModelOptions(draftCandidate, provider);
565635 const prompt = ensureInlineTerminalContextPlaceholders(
566636 promptCandidate,
567637 terminalContexts.length,
......@@ -572,10 +642,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
572642 terminalContexts.length === 0 &&
573643 !provider &&
574644 !model &&
645 modelOptions === null &&
575646 !runtimeMode &&
576 !interactionMode &&
577 !effort &&
578 !codexFastMode
647 !interactionMode
579648 ) {
580649 continue;
581650 }
......@@ -585,32 +654,120 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
585654 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
586655 ...(provider ? { provider } : {}),
587656 ...(model ? { model } : {}),
657 ...(modelOptions ? { modelOptions } : {}),
588658 ...(runtimeMode ? { runtimeMode } : {}),
589659 ...(interactionMode ? { interactionMode } : {}),
590 ...(effort ? { effort } : {}),
591 ...(codexFastMode ? { codexFastMode } : {}),
592660 };
593661 }
662
663 return nextDraftsByThreadId;
664}
665
666function migratePersistedComposerDraftStoreState(
667 persistedState: unknown,
668 persistedVersion: number,
669): PersistedComposerDraftStoreState {
670 if (!persistedState || typeof persistedState !== "object") {
671 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
672 }
673 const candidate = persistedState as Record<string, unknown>;
674 const rawDraftMap = candidate.draftsByThreadId;
675 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
676 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
677 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
678 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
679 const draftsByThreadId = normalizePersistedDraftsByThreadId(
680 rawDraftMap,
681 (draftCandidate, provider) =>
682 persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION
683 ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider)
684 : normalizeProviderModelOptions(
685 draftCandidate.modelOptions,
686 provider,
687 draftCandidate as LegacyPersistedCodexThreadDraftState,
688 ),
689 );
594690 return {
595 draftsByThreadId: nextDraftsByThreadId,
691 draftsByThreadId,
596692 draftThreadsByThreadId,
597693 projectDraftThreadIdByProjectId,
598694 };
599695}
600696
601function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState {
602 if (!raw) {
603 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
604 }
605 try {
606 const parsed = JSON.parse(raw) as unknown;
607 if (parsed && typeof parsed === "object" && "state" in parsed) {
608 return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state);
697function partializeComposerDraftStoreState(
698 state: ComposerDraftStoreState,
699): PersistedComposerDraftStoreState {
700 const persistedDraftsByThreadId: DeepMutable<
701 PersistedComposerDraftStoreState["draftsByThreadId"]
702 > = {};
703 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
704 if (typeof threadId !== "string" || threadId.length === 0) {
705 continue;
609706 }
610 return normalizePersistedComposerDraftState(parsed);
611 } catch {
707 if (
708 draft.prompt.length === 0 &&
709 draft.persistedAttachments.length === 0 &&
710 draft.terminalContexts.length === 0 &&
711 draft.provider === null &&
712 draft.model === null &&
713 draft.modelOptions === null &&
714 draft.runtimeMode === null &&
715 draft.interactionMode === null
716 ) {
717 continue;
718 }
719 const persistedDraft: DeepMutable<PersistedComposerThreadDraftState> = {
720 prompt: draft.prompt,
721 attachments: draft.persistedAttachments,
722 ...(draft.terminalContexts.length > 0
723 ? {
724 terminalContexts: draft.terminalContexts.map((context) => ({
725 id: context.id,
726 threadId: context.threadId,
727 createdAt: context.createdAt,
728 terminalId: context.terminalId,
729 terminalLabel: context.terminalLabel,
730 lineStart: context.lineStart,
731 lineEnd: context.lineEnd,
732 })),
733 }
734 : {}),
735 ...(draft.model ? { model: draft.model } : {}),
736 ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}),
737 ...(draft.provider ? { provider: draft.provider } : {}),
738 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
739 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
740 };
741 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
742 }
743 return {
744 draftsByThreadId: persistedDraftsByThreadId,
745 draftThreadsByThreadId: state.draftThreadsByThreadId,
746 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
747 };
748}
749
750function normalizeCurrentPersistedComposerDraftStoreState(
751 persistedState: unknown,
752): PersistedComposerDraftStoreState {
753 if (!persistedState || typeof persistedState !== "object") {
612754 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
613755 }
756 const normalizedPersistedState = persistedState as Record<string, unknown>;
757 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
758 normalizePersistedDraftThreads(
759 normalizedPersistedState.draftThreadsByThreadId,
760 normalizedPersistedState.projectDraftThreadIdByProjectId,
761 );
762 return {
763 draftsByThreadId: normalizePersistedDraftsByThreadId(
764 normalizedPersistedState.draftsByThreadId,
765 (draftCandidate, provider) =>
766 normalizeProviderModelOptions(draftCandidate.modelOptions, provider),
767 ),
768 draftThreadsByThreadId,
769 projectDraftThreadIdByProjectId,
770 };
614771}
615772
616773function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
......@@ -618,9 +775,14 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
618775 return [];
619776 }
620777 try {
621 const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
622 const persisted = parsePersistedDraftStateRaw(raw);
623 return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map(
778 const persisted = getLocalStorageItem(
779 COMPOSER_DRAFT_STORAGE_KEY,
780 PersistedComposerDraftStoreStorage,
781 );
782 if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
783 return [];
784 }
785 return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map(
624786 (attachment) => attachment.id,
625787 );
626788 } catch {
......@@ -661,7 +823,7 @@ function hydreatePersistedComposerImageAttachment(
661823}
662824
663825function hydrateImagesFromPersisted(
664 attachments: PersistedComposerImageAttachment[],
826 attachments: ReadonlyArray<PersistedComposerImageAttachment>,
665827): ComposerImageAttachment[] {
666828 return attachments.flatMap((attachment) => {
667829 const file = hydreatePersistedComposerImageAttachment(attachment);
......@@ -688,7 +850,7 @@ function toHydratedThreadDraft(
688850 prompt: persistedDraft.prompt,
689851 images: hydrateImagesFromPersisted(persistedDraft.attachments),
690852 nonPersistedImageIds: [],
691 persistedAttachments: persistedDraft.attachments,
853 persistedAttachments: [...persistedDraft.attachments],
692854 terminalContexts:
693855 persistedDraft.terminalContexts?.map((context) => ({
694856 ...context,
......@@ -696,10 +858,9 @@ function toHydratedThreadDraft(
696858 })) ?? [],
697859 provider: persistedDraft.provider ?? null,
698860 model: persistedDraft.model ?? null,
861 modelOptions: persistedDraft.modelOptions ?? null,
699862 runtimeMode: persistedDraft.runtimeMode ?? null,
700863 interactionMode: persistedDraft.interactionMode ?? null,
701 effort: persistedDraft.effort ?? null,
702 codexFastMode: persistedDraft.codexFastMode === true,
703864 };
704865}
705866
......@@ -1018,9 +1179,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
10181179 if (threadId.length === 0) {
10191180 return;
10201181 }
1021 const normalizedModel = normalizeModelSlug(model) ?? null;
10221182 set((state) => {
10231183 const existing = state.draftsByThreadId[threadId];
1184 const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null;
10241185 if (!existing && normalizedModel === null) {
10251186 return state;
10261187 }
......@@ -1041,24 +1202,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
10411202 return { draftsByThreadId: nextDraftsByThreadId };
10421203 });
10431204 },
1044 setRuntimeMode: (threadId, runtimeMode) => {
1205 setModelOptions: (threadId, modelOptions) => {
10451206 if (threadId.length === 0) {
10461207 return;
10471208 }
1048 const nextRuntimeMode =
1049 runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
10501209 set((state) => {
10511210 const existing = state.draftsByThreadId[threadId];
1052 if (!existing && nextRuntimeMode === null) {
1211 const provider = existing?.provider ?? null;
1212 const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider);
1213 if (!existing && nextModelOptions === null) {
10531214 return state;
10541215 }
10551216 const base = existing ?? createEmptyThreadDraft();
1056 if (base.runtimeMode === nextRuntimeMode) {
1217 if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) {
10571218 return state;
10581219 }
10591220 const nextDraft: ComposerThreadDraftState = {
10601221 ...base,
1061 runtimeMode: nextRuntimeMode,
1222 modelOptions: nextModelOptions,
10621223 };
10631224 const nextDraftsByThreadId = { ...state.draftsByThreadId };
10641225 if (shouldRemoveDraft(nextDraft)) {
......@@ -1069,56 +1230,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
10691230 return { draftsByThreadId: nextDraftsByThreadId };
10701231 });
10711232 },
1072 setInteractionMode: (threadId, interactionMode) => {
1073 if (threadId.length === 0) {
1074 return;
1075 }
1076 const nextInteractionMode =
1077 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
1078 set((state) => {
1079 const existing = state.draftsByThreadId[threadId];
1080 if (!existing && nextInteractionMode === null) {
1081 return state;
1082 }
1083 const base = existing ?? createEmptyThreadDraft();
1084 if (base.interactionMode === nextInteractionMode) {
1085 return state;
1086 }
1087 const nextDraft: ComposerThreadDraftState = {
1088 ...base,
1089 interactionMode: nextInteractionMode,
1090 };
1091 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1092 if (shouldRemoveDraft(nextDraft)) {
1093 delete nextDraftsByThreadId[threadId];
1094 } else {
1095 nextDraftsByThreadId[threadId] = nextDraft;
1096 }
1097 return { draftsByThreadId: nextDraftsByThreadId };
1098 });
1099 },
1100 setEffort: (threadId, effort) => {
1233 setRuntimeMode: (threadId, runtimeMode) => {
11011234 if (threadId.length === 0) {
11021235 return;
11031236 }
1104 const nextEffort =
1105 effort &&
1106 REASONING_EFFORT_VALUES.has(effort) &&
1107 effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex
1108 ? effort
1109 : null;
1237 const nextRuntimeMode =
1238 runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
11101239 set((state) => {
11111240 const existing = state.draftsByThreadId[threadId];
1112 if (!existing && nextEffort === null) {
1241 if (!existing && nextRuntimeMode === null) {
11131242 return state;
11141243 }
11151244 const base = existing ?? createEmptyThreadDraft();
1116 if (base.effort === nextEffort) {
1245 if (base.runtimeMode === nextRuntimeMode) {
11171246 return state;
11181247 }
11191248 const nextDraft: ComposerThreadDraftState = {
11201249 ...base,
1121 effort: nextEffort,
1250 runtimeMode: nextRuntimeMode,
11221251 };
11231252 const nextDraftsByThreadId = { ...state.draftsByThreadId };
11241253 if (shouldRemoveDraft(nextDraft)) {
......@@ -1129,23 +1258,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
11291258 return { draftsByThreadId: nextDraftsByThreadId };
11301259 });
11311260 },
1132 setCodexFastMode: (threadId, enabled) => {
1261 setInteractionMode: (threadId, interactionMode) => {
11331262 if (threadId.length === 0) {
11341263 return;
11351264 }
1136 const nextCodexFastMode = enabled === true;
1265 const nextInteractionMode =
1266 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
11371267 set((state) => {
11381268 const existing = state.draftsByThreadId[threadId];
1139 if (!existing && nextCodexFastMode === false) {
1269 if (!existing && nextInteractionMode === null) {
11401270 return state;
11411271 }
11421272 const base = existing ?? createEmptyThreadDraft();
1143 if (base.codexFastMode === nextCodexFastMode) {
1273 if (base.interactionMode === nextInteractionMode) {
11441274 return state;
11451275 }
11461276 const nextDraft: ComposerThreadDraftState = {
11471277 ...base,
1148 codexFastMode: nextCodexFastMode,
1278 interactionMode: nextInteractionMode,
11491279 };
11501280 const nextDraftsByThreadId = { ...state.draftsByThreadId };
11511281 if (shouldRemoveDraft(nextDraft)) {
......@@ -1496,70 +1626,13 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
14961626 }),
14971627 {
14981628 name: COMPOSER_DRAFT_STORAGE_KEY,
1499 version: 1,
1629 version: COMPOSER_DRAFT_STORAGE_VERSION,
15001630 storage: createJSONStorage(() => composerDebouncedStorage),
1501 partialize: (state) => {
1502 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
1503 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
1504 if (typeof threadId !== "string" || threadId.length === 0) {
1505 continue;
1506 }
1507 if (
1508 draft.prompt.length === 0 &&
1509 draft.persistedAttachments.length === 0 &&
1510 draft.terminalContexts.length === 0 &&
1511 draft.provider === null &&
1512 draft.model === null &&
1513 draft.runtimeMode === null &&
1514 draft.interactionMode === null &&
1515 draft.effort === null &&
1516 draft.codexFastMode === false
1517 ) {
1518 continue;
1519 }
1520 const persistedDraft: PersistedComposerThreadDraftState = {
1521 prompt: draft.prompt,
1522 attachments: draft.persistedAttachments,
1523 };
1524 if (draft.terminalContexts.length > 0) {
1525 persistedDraft.terminalContexts = draft.terminalContexts.map((context) => ({
1526 id: context.id,
1527 threadId: context.threadId,
1528 createdAt: context.createdAt,
1529 terminalId: context.terminalId,
1530 terminalLabel: context.terminalLabel,
1531 lineStart: context.lineStart,
1532 lineEnd: context.lineEnd,
1533 }));
1534 }
1535 if (draft.model) {
1536 persistedDraft.model = draft.model;
1537 }
1538 if (draft.provider) {
1539 persistedDraft.provider = draft.provider;
1540 }
1541 if (draft.runtimeMode) {
1542 persistedDraft.runtimeMode = draft.runtimeMode;
1543 }
1544 if (draft.interactionMode) {
1545 persistedDraft.interactionMode = draft.interactionMode;
1546 }
1547 if (draft.effort) {
1548 persistedDraft.effort = draft.effort;
1549 }
1550 if (draft.codexFastMode) {
1551 persistedDraft.codexFastMode = true;
1552 }
1553 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
1554 }
1555 return {
1556 draftsByThreadId: persistedDraftsByThreadId,
1557 draftThreadsByThreadId: state.draftThreadsByThreadId,
1558 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
1559 };
1560 },
1631 migrate: migratePersistedComposerDraftStoreState,
1632 partialize: partializeComposerDraftStoreState,
15611633 merge: (persistedState, currentState) => {
1562 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);
1634 const normalizedPersisted =
1635 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
15631636 const draftsByThreadId = Object.fromEntries(
15641637 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
15651638 threadId,
apps/web/src/index.css+63
......@@ -404,3 +404,66 @@ label:has(> select#reasoning-effort) select {
404404 overflow: clip;
405405 background: color-mix(in srgb, var(--card) 92%, var(--background));
406406}
407
408@keyframes ultrathink-rainbow {
409 0% {
410 background-position: 0% 50%;
411 }
412 100% {
413 background-position: 200% 50%;
414 }
415}
416
417@keyframes ultrathink-chroma-shift {
418 0% {
419 filter: hue-rotate(0deg) saturate(1.2);
420 }
421 100% {
422 filter: hue-rotate(360deg) saturate(1.2);
423 }
424}
425
426.ultrathink-frame {
427 --ultrathink-spectrum: linear-gradient(
428 120deg,
429 #ff6b6b 0%,
430 #f59e0b 18%,
431 #22c55e 36%,
432 #14b8a6 54%,
433 #3b82f6 72%,
434 #ec4899 90%,
435 #ff6b6b 100%
436 );
437 background-image: var(--ultrathink-spectrum);
438 background-size: 220% 220%;
439 animation: ultrathink-rainbow 10s linear infinite;
440}
441
442.ultrathink-chroma {
443 animation: ultrathink-chroma-shift 10s linear infinite;
444}
445
446.ultrathink-pill {
447 background:
448 linear-gradient(var(--card), var(--card)) padding-box,
449 var(--ultrathink-spectrum) border-box;
450 background-size:
451 100% 100%,
452 220% 220%;
453 background-position:
454 0 0,
455 0% 50%;
456 animation: ultrathink-rainbow 10s linear infinite;
457 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent);
458}
459
460.ultrathink-word {
461 display: inline-block;
462 color: transparent;
463 background-image: var(--ultrathink-spectrum);
464 background-size: 220% 220%;
465 background-position: 0% 50%;
466 background-clip: text;
467 -webkit-background-clip: text;
468 animation: ultrathink-rainbow 10s linear infinite;
469}
apps/web/src/lib/storage.ts created+50
......@@ -0,0 +1,50 @@
1import { Debouncer } from "@tanstack/react-pacer";
2
3export interface StateStorage<R = unknown> {
4 getItem: (name: string) => string | null | Promise<string | null>;
5 setItem: (name: string, value: string) => R;
6 removeItem: (name: string) => R;
7}
8
9export interface DebouncedStorage<R = unknown> extends StateStorage<R> {
10 flush: () => void;
11}
12
13export function createMemoryStorage(): StateStorage {
14 const store = new Map<string, string>();
15 return {
16 getItem: (name) => store.get(name) ?? null,
17 setItem: (name, value) => {
18 store.set(name, value);
19 },
20 removeItem: (name) => {
21 store.delete(name);
22 },
23 };
24}
25
26export function createDebouncedStorage(
27 baseStorage: StateStorage,
28 debounceMs: number = 300,
29): DebouncedStorage {
30 const debouncedSetItem = new Debouncer(
31 (name: string, value: string) => {
32 baseStorage.setItem(name, value);
33 },
34 { wait: debounceMs },
35 );
36
37 return {
38 getItem: (name) => baseStorage.getItem(name),
39 setItem: (name, value) => {
40 debouncedSetItem.maybeExecute(name, value);
41 },
42 removeItem: (name) => {
43 debouncedSetItem.cancel();
44 baseStorage.removeItem(name);
45 },
46 flush: () => {
47 debouncedSetItem.flush();
48 },
49 };
50}
apps/web/src/routes/_chat.settings.tsx+14
......@@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{
5454 placeholder: "your-codex-model-slug",
5555 example: "gpt-6.7-codex-ultra-preview",
5656 },
57 {
58 provider: "claudeAgent",
59 title: "Claude",
60 description: "Save additional Claude model slugs for the picker and `/model` command.",
61 placeholder: "your-claude-model-slug",
62 example: "claude-sonnet-5-0",
63 },
5764] as const;
5865
5966const TIMESTAMP_FORMAT_LABELS = {
......@@ -67,6 +74,8 @@ function getCustomModelsForProvider(
6774 provider: ProviderKind,
6875) {
6976 switch (provider) {
77 case "claudeAgent":
78 return settings.customClaudeModels;
7079 case "codex":
7180 default:
7281 return settings.customCodexModels;
......@@ -78,6 +87,8 @@ function getDefaultCustomModelsForProvider(
7887 provider: ProviderKind,
7988) {
8089 switch (provider) {
90 case "claudeAgent":
91 return defaults.customClaudeModels;
8192 case "codex":
8293 default:
8394 return defaults.customCodexModels;
......@@ -86,6 +97,8 @@ function getDefaultCustomModelsForProvider(
8697
8798function patchCustomModels(provider: ProviderKind, models: string[]) {
8899 switch (provider) {
100 case "claudeAgent":
101 return { customClaudeModels: models };
89102 case "codex":
90103 default:
91104 return { customCodexModels: models };
......@@ -102,6 +115,7 @@ function SettingsRouteView() {
102115 Record<ProviderKind, string>
103116 >({
104117 codex: "",
118 claudeAgent: "",
105119 });
106120 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
107121 Partial<Record<ProviderKind, string | null>>
apps/web/src/session-logic.test.ts+364-6
......@@ -16,6 +16,7 @@ import {
1616 deriveTimelineEntries,
1717 deriveWorkLogEntries,
1818 findLatestProposedPlan,
19 findSidebarProposedPlan,
1920 hasActionableProposedPlan,
2021 hasToolActivityForTurn,
2122 isLatestTurnSettled,
......@@ -141,6 +142,36 @@ describe("derivePendingApprovals", () => {
141142
142143 expect(derivePendingApprovals(activities)).toEqual([]);
143144 });
145
146 it("clears stale pending approvals when the backend marks them stale after restart", () => {
147 const activities: OrchestrationThreadActivity[] = [
148 makeActivity({
149 id: "approval-open-stale-restart",
150 createdAt: "2026-02-23T00:00:01.000Z",
151 kind: "approval.requested",
152 summary: "Command approval requested",
153 tone: "approval",
154 payload: {
155 requestId: "req-stale-restart-1",
156 requestKind: "command",
157 },
158 }),
159 makeActivity({
160 id: "approval-failed-stale-restart",
161 createdAt: "2026-02-23T00:00:02.000Z",
162 kind: "provider.approval.respond.failed",
163 summary: "Provider approval response failed",
164 tone: "error",
165 payload: {
166 requestId: "req-stale-restart-1",
167 detail:
168 "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
169 },
170 }),
171 ];
172
173 expect(derivePendingApprovals(activities)).toEqual([]);
174 });
144175});
145176
146177describe("derivePendingUserInputs", () => {
......@@ -227,6 +258,48 @@ describe("derivePendingUserInputs", () => {
227258 },
228259 ]);
229260 });
261
262 it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
263 const activities: OrchestrationThreadActivity[] = [
264 makeActivity({
265 id: "user-input-open-stale",
266 createdAt: "2026-02-23T00:00:01.000Z",
267 kind: "user-input.requested",
268 summary: "User input requested",
269 tone: "info",
270 payload: {
271 requestId: "req-user-input-stale-1",
272 questions: [
273 {
274 id: "sandbox_mode",
275 header: "Sandbox",
276 question: "Which mode should be used?",
277 options: [
278 {
279 label: "workspace-write",
280 description: "Allow workspace writes only",
281 },
282 ],
283 },
284 ],
285 },
286 }),
287 makeActivity({
288 id: "user-input-failed-stale",
289 createdAt: "2026-02-23T00:00:02.000Z",
290 kind: "provider.user-input.respond.failed",
291 summary: "Provider user input response failed",
292 tone: "error",
293 payload: {
294 requestId: "req-user-input-stale-1",
295 detail:
296 "Stale pending user-input request: req-user-input-stale-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
297 },
298 }),
299 ];
300
301 expect(derivePendingUserInputs(activities)).toEqual([]);
302 });
230303});
231304
232305describe("deriveActivePlanState", () => {
......@@ -372,6 +445,103 @@ describe("hasActionableProposedPlan", () => {
372445 });
373446});
374447
448describe("findSidebarProposedPlan", () => {
449 it("prefers the running turn source proposed plan when available on the same thread", () => {
450 expect(
451 findSidebarProposedPlan({
452 threads: [
453 {
454 id: ThreadId.makeUnsafe("thread-1"),
455 proposedPlans: [
456 {
457 id: "plan-1",
458 turnId: TurnId.makeUnsafe("turn-plan"),
459 planMarkdown: "# Source plan",
460 implementedAt: "2026-02-23T00:00:03.000Z",
461 implementationThreadId: ThreadId.makeUnsafe("thread-2"),
462 createdAt: "2026-02-23T00:00:01.000Z",
463 updatedAt: "2026-02-23T00:00:02.000Z",
464 },
465 ],
466 },
467 {
468 id: ThreadId.makeUnsafe("thread-2"),
469 proposedPlans: [
470 {
471 id: "plan-2",
472 turnId: TurnId.makeUnsafe("turn-other"),
473 planMarkdown: "# Latest elsewhere",
474 implementedAt: null,
475 implementationThreadId: null,
476 createdAt: "2026-02-23T00:00:04.000Z",
477 updatedAt: "2026-02-23T00:00:05.000Z",
478 },
479 ],
480 },
481 ],
482 latestTurn: {
483 turnId: TurnId.makeUnsafe("turn-implementation"),
484 sourceProposedPlan: {
485 threadId: ThreadId.makeUnsafe("thread-1"),
486 planId: "plan-1",
487 },
488 },
489 latestTurnSettled: false,
490 threadId: ThreadId.makeUnsafe("thread-1"),
491 }),
492 ).toEqual({
493 id: "plan-1",
494 turnId: "turn-plan",
495 planMarkdown: "# Source plan",
496 implementedAt: "2026-02-23T00:00:03.000Z",
497 implementationThreadId: "thread-2",
498 createdAt: "2026-02-23T00:00:01.000Z",
499 updatedAt: "2026-02-23T00:00:02.000Z",
500 });
501 });
502
503 it("falls back to the latest proposed plan once the turn is settled", () => {
504 expect(
505 findSidebarProposedPlan({
506 threads: [
507 {
508 id: ThreadId.makeUnsafe("thread-1"),
509 proposedPlans: [
510 {
511 id: "plan-1",
512 turnId: TurnId.makeUnsafe("turn-plan"),
513 planMarkdown: "# Older",
514 implementedAt: null,
515 implementationThreadId: null,
516 createdAt: "2026-02-23T00:00:01.000Z",
517 updatedAt: "2026-02-23T00:00:02.000Z",
518 },
519 {
520 id: "plan-2",
521 turnId: TurnId.makeUnsafe("turn-latest"),
522 planMarkdown: "# Latest",
523 implementedAt: null,
524 implementationThreadId: null,
525 createdAt: "2026-02-23T00:00:03.000Z",
526 updatedAt: "2026-02-23T00:00:04.000Z",
527 },
528 ],
529 },
530 ],
531 latestTurn: {
532 turnId: TurnId.makeUnsafe("turn-implementation"),
533 sourceProposedPlan: {
534 threadId: ThreadId.makeUnsafe("thread-1"),
535 planId: "plan-1",
536 },
537 },
538 latestTurnSettled: true,
539 threadId: ThreadId.makeUnsafe("thread-1"),
540 })?.planMarkdown,
541 ).toBe("# Latest");
542 });
543});
544
375545describe("deriveWorkLogEntries", () => {
376546 it("omits tool started entries and keeps completed entries", () => {
377547 const activities: OrchestrationThreadActivity[] = [
......@@ -459,6 +629,42 @@ describe("deriveWorkLogEntries", () => {
459629 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
460630 });
461631
632 it("omits ExitPlanMode lifecycle entries once the plan card is shown", () => {
633 const activities: OrchestrationThreadActivity[] = [
634 makeActivity({
635 id: "exit-plan-updated",
636 createdAt: "2026-02-23T00:00:01.000Z",
637 kind: "tool.updated",
638 summary: "Tool call",
639 payload: {
640 detail: 'ExitPlanMode: {"allowedPrompts":[{"tool":"Bash","prompt":"run tests"}]}',
641 },
642 }),
643 makeActivity({
644 id: "exit-plan-completed",
645 createdAt: "2026-02-23T00:00:02.000Z",
646 kind: "tool.completed",
647 summary: "Tool call",
648 payload: {
649 detail: "ExitPlanMode: {}",
650 },
651 }),
652 makeActivity({
653 id: "real-work-log",
654 createdAt: "2026-02-23T00:00:03.000Z",
655 kind: "tool.completed",
656 summary: "Ran command",
657 payload: {
658 itemType: "command_execution",
659 detail: "Bash: bun test",
660 },
661 }),
662 ];
663
664 const entries = deriveWorkLogEntries(activities, undefined);
665 expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]);
666 });
667
462668 it("orders work log by activity sequence when present", () => {
463669 const activities: OrchestrationThreadActivity[] = [
464670 makeActivity({
......@@ -561,6 +767,158 @@ describe("deriveWorkLogEntries", () => {
561767 "apps/web/src/session-logic.ts",
562768 ]);
563769 });
770
771 it("collapses repeated lifecycle updates for the same tool call into one entry", () => {
772 const activities: OrchestrationThreadActivity[] = [
773 makeActivity({
774 id: "tool-update-1",
775 createdAt: "2026-02-23T00:00:01.000Z",
776 kind: "tool.updated",
777 summary: "Tool call",
778 payload: {
779 itemType: "dynamic_tool_call",
780 title: "Tool call",
781 detail: 'Read: {"file_path":"/tmp/app.ts"}',
782 },
783 }),
784 makeActivity({
785 id: "tool-update-2",
786 createdAt: "2026-02-23T00:00:02.000Z",
787 kind: "tool.updated",
788 summary: "Tool call",
789 payload: {
790 itemType: "dynamic_tool_call",
791 title: "Tool call",
792 detail: 'Read: {"file_path":"/tmp/app.ts"}',
793 data: {
794 item: {
795 command: ["sed", "-n", "1,40p", "/tmp/app.ts"],
796 },
797 },
798 },
799 }),
800 makeActivity({
801 id: "tool-complete",
802 createdAt: "2026-02-23T00:00:03.000Z",
803 kind: "tool.completed",
804 summary: "Tool call completed",
805 payload: {
806 itemType: "dynamic_tool_call",
807 title: "Tool call",
808 detail: 'Read: {"file_path":"/tmp/app.ts"}',
809 },
810 }),
811 ];
812
813 const entries = deriveWorkLogEntries(activities, undefined);
814
815 expect(entries).toHaveLength(1);
816 expect(entries[0]).toMatchObject({
817 id: "tool-complete",
818 createdAt: "2026-02-23T00:00:03.000Z",
819 label: "Tool call completed",
820 detail: 'Read: {"file_path":"/tmp/app.ts"}',
821 command: "sed -n 1,40p /tmp/app.ts",
822 itemType: "dynamic_tool_call",
823 toolTitle: "Tool call",
824 });
825 });
826
827 it("keeps separate tool entries when an identical call starts after the prior one completed", () => {
828 const activities: OrchestrationThreadActivity[] = [
829 makeActivity({
830 id: "tool-1-update",
831 createdAt: "2026-02-23T00:00:01.000Z",
832 kind: "tool.updated",
833 summary: "Tool call",
834 payload: {
835 itemType: "dynamic_tool_call",
836 title: "Tool call",
837 detail: 'Read: {"file_path":"/tmp/app.ts"}',
838 },
839 }),
840 makeActivity({
841 id: "tool-1-complete",
842 createdAt: "2026-02-23T00:00:02.000Z",
843 kind: "tool.completed",
844 summary: "Tool call completed",
845 payload: {
846 itemType: "dynamic_tool_call",
847 title: "Tool call",
848 detail: 'Read: {"file_path":"/tmp/app.ts"}',
849 },
850 }),
851 makeActivity({
852 id: "tool-2-update",
853 createdAt: "2026-02-23T00:00:03.000Z",
854 kind: "tool.updated",
855 summary: "Tool call",
856 payload: {
857 itemType: "dynamic_tool_call",
858 title: "Tool call",
859 detail: 'Read: {"file_path":"/tmp/app.ts"}',
860 },
861 }),
862 makeActivity({
863 id: "tool-2-complete",
864 createdAt: "2026-02-23T00:00:04.000Z",
865 kind: "tool.completed",
866 summary: "Tool call completed",
867 payload: {
868 itemType: "dynamic_tool_call",
869 title: "Tool call",
870 detail: 'Read: {"file_path":"/tmp/app.ts"}',
871 },
872 }),
873 ];
874
875 const entries = deriveWorkLogEntries(activities, undefined);
876
877 expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]);
878 });
879
880 it("collapses same-timestamp lifecycle rows even when completed sorts before updated by id", () => {
881 const activities: OrchestrationThreadActivity[] = [
882 makeActivity({
883 id: "z-update-earlier",
884 createdAt: "2026-02-23T00:00:01.000Z",
885 kind: "tool.updated",
886 summary: "Tool call",
887 payload: {
888 itemType: "dynamic_tool_call",
889 title: "Tool call",
890 detail: 'Read: {"file_path":"/tmp/app.ts"}',
891 },
892 }),
893 makeActivity({
894 id: "a-complete-same-timestamp",
895 createdAt: "2026-02-23T00:00:02.000Z",
896 kind: "tool.completed",
897 summary: "Tool call",
898 payload: {
899 itemType: "dynamic_tool_call",
900 title: "Tool call",
901 detail: 'Read: {"file_path":"/tmp/app.ts"}',
902 },
903 }),
904 makeActivity({
905 id: "z-update-same-timestamp",
906 createdAt: "2026-02-23T00:00:02.000Z",
907 kind: "tool.updated",
908 summary: "Tool call",
909 payload: {
910 itemType: "dynamic_tool_call",
911 title: "Tool call",
912 detail: 'Read: {"file_path":"/tmp/app.ts"}',
913 },
914 }),
915 ];
916
917 const entries = deriveWorkLogEntries(activities, undefined);
918
919 expect(entries).toHaveLength(1);
920 expect(entries[0]?.id).toBe("a-complete-same-timestamp");
921 });
564922});
565923
566924describe("deriveTimelineEntries", () => {
......@@ -726,18 +1084,18 @@ describe("deriveActiveWorkStartedAt", () => {
7261084});
7271085
7281086describe("PROVIDER_OPTIONS", () => {
729 it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => {
730 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
1087 it("advertises Claude as available while keeping Cursor as a placeholder", () => {
1088 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");
7311089 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
7321090 expect(PROVIDER_OPTIONS).toEqual([
7331091 { value: "codex", label: "Codex", available: true },
734 { value: "claudeCode", label: "Claude Code", available: false },
1092 { value: "claudeAgent", label: "Claude", available: true },
7351093 { value: "cursor", label: "Cursor", available: false },
7361094 ]);
7371095 expect(claude).toEqual({
738 value: "claudeCode",
739 label: "Claude Code",
740 available: false,
1096 value: "claudeAgent",
1097 label: "Claude",
1098 available: true,
7411099 });
7421100 expect(cursor).toEqual({
7431101 value: "cursor",
apps/web/src/session-logic.ts+242-62
......@@ -15,11 +15,12 @@ import type {
1515 ChatMessage,
1616 ProposedPlan,
1717 SessionPhase,
18 Thread,
1819 ThreadSession,
1920 TurnDiffSummary,
2021} from "./types";
2122
22export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";
23export type ProviderPickerKind = ProviderKind | "cursor";
2324
2425export const PROVIDER_OPTIONS: Array<{
2526 value: ProviderPickerKind;
......@@ -27,7 +28,7 @@ export const PROVIDER_OPTIONS: Array<{
2728 available: boolean;
2829}> = [
2930 { value: "codex", label: "Codex", available: true },
30 { value: "claudeCode", label: "Claude Code", available: false },
31 { value: "claudeAgent", label: "Claude", available: true },
3132 { value: "cursor", label: "Cursor", available: false },
3233];
3334
......@@ -44,6 +45,11 @@ export interface WorkLogEntry {
4445 requestKind?: PendingApproval["requestKind"];
4546}
4647
48interface DerivedWorkLogEntry extends WorkLogEntry {
49 activityKind: OrchestrationThreadActivity["kind"];
50 collapseKey?: string;
51}
52
4753export interface PendingApproval {
4854 requestId: ApprovalRequestId;
4955 requestKind: "command" | "file-read" | "file-change";
......@@ -159,6 +165,20 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
159165 }
160166}
161167
168function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
169 const normalized = detail?.toLowerCase();
170 if (!normalized) {
171 return false;
172 }
173 return (
174 normalized.includes("stale pending approval request") ||
175 normalized.includes("stale pending user-input request") ||
176 normalized.includes("unknown pending approval request") ||
177 normalized.includes("unknown pending permission request") ||
178 normalized.includes("unknown pending user-input request")
179 );
180}
181
162182export function derivePendingApprovals(
163183 activities: ReadonlyArray<OrchestrationThreadActivity>,
164184): PendingApproval[] {
......@@ -203,7 +223,7 @@ export function derivePendingApprovals(
203223 if (
204224 activity.kind === "provider.approval.respond.failed" &&
205225 requestId &&
206 detail?.includes("Unknown pending permission request")
226 isStalePendingRequestFailureDetail(detail)
207227 ) {
208228 openByRequestId.delete(requestId);
209229 continue;
......@@ -279,6 +299,7 @@ export function derivePendingUserInputs(
279299 payload && typeof payload.requestId === "string"
280300 ? ApprovalRequestId.makeUnsafe(payload.requestId)
281301 : null;
302 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
282303
283304 if (activity.kind === "user-input.requested" && requestId) {
284305 const questions = parseUserInputQuestions(payload);
......@@ -295,6 +316,15 @@ export function derivePendingUserInputs(
295316
296317 if (activity.kind === "user-input.resolved" && requestId) {
297318 openByRequestId.delete(requestId);
319 continue;
320 }
321
322 if (
323 activity.kind === "provider.user-input.respond.failed" &&
324 requestId &&
325 isStalePendingRequestFailureDetail(detail)
326 ) {
327 openByRequestId.delete(requestId);
298328 }
299329 }
300330
......@@ -377,15 +407,7 @@ export function findLatestProposedPlan(
377407 )
378408 .at(-1);
379409 if (matchingTurnPlan) {
380 return {
381 id: matchingTurnPlan.id,
382 createdAt: matchingTurnPlan.createdAt,
383 updatedAt: matchingTurnPlan.updatedAt,
384 turnId: matchingTurnPlan.turnId,
385 planMarkdown: matchingTurnPlan.planMarkdown,
386 implementedAt: matchingTurnPlan.implementedAt,
387 implementationThreadId: matchingTurnPlan.implementationThreadId,
388 };
410 return toLatestProposedPlanState(matchingTurnPlan);
389411 }
390412 }
391413
......@@ -399,15 +421,31 @@ export function findLatestProposedPlan(
399421 return null;
400422 }
401423
402 return {
403 id: latestPlan.id,
404 createdAt: latestPlan.createdAt,
405 updatedAt: latestPlan.updatedAt,
406 turnId: latestPlan.turnId,
407 planMarkdown: latestPlan.planMarkdown,
408 implementedAt: latestPlan.implementedAt,
409 implementationThreadId: latestPlan.implementationThreadId,
410 };
424 return toLatestProposedPlanState(latestPlan);
425}
426
427export function findSidebarProposedPlan(input: {
428 threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>;
429 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null;
430 latestTurnSettled: boolean;
431 threadId: ThreadId | string | null | undefined;
432}): LatestProposedPlanState | null {
433 const activeThreadPlans =
434 input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];
435
436 if (!input.latestTurnSettled) {
437 const sourceProposedPlan = input.latestTurn?.sourceProposedPlan;
438 if (sourceProposedPlan) {
439 const sourcePlan = input.threads
440 .find((thread) => thread.id === sourceProposedPlan.threadId)
441 ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId);
442 if (sourcePlan) {
443 return toLatestProposedPlanState(sourcePlan);
444 }
445 }
446 }
447
448 return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null);
411449}
412450
413451export function hasActionableProposedPlan(
......@@ -421,50 +459,168 @@ export function deriveWorkLogEntries(
421459 latestTurnId: TurnId | undefined,
422460): WorkLogEntry[] {
423461 const ordered = [...activities].toSorted(compareActivitiesByOrder);
424 return ordered
462 const entries = ordered
425463 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
426464 .filter((activity) => activity.kind !== "tool.started")
427465 .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
428466 .filter((activity) => activity.summary !== "Checkpoint captured")
429 .map((activity) => {
430 const payload =
431 activity.payload && typeof activity.payload === "object"
432 ? (activity.payload as Record<string, unknown>)
433 : null;
434 const command = extractToolCommand(payload);
435 const changedFiles = extractChangedFiles(payload);
436 const title = extractToolTitle(payload);
437 const entry: WorkLogEntry = {
438 id: activity.id,
439 createdAt: activity.createdAt,
440 label: activity.summary,
441 tone: activity.tone === "approval" ? "info" : activity.tone,
442 };
443 const itemType = extractWorkLogItemType(payload);
444 const requestKind = extractWorkLogRequestKind(payload);
445 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
446 const detail = stripTrailingExitCode(payload.detail).output;
447 if (detail) {
448 entry.detail = detail;
449 }
450 }
451 if (command) {
452 entry.command = command;
453 }
454 if (changedFiles.length > 0) {
455 entry.changedFiles = changedFiles;
456 }
457 if (title) {
458 entry.toolTitle = title;
459 }
460 if (itemType) {
461 entry.itemType = itemType;
462 }
463 if (requestKind) {
464 entry.requestKind = requestKind;
465 }
466 return entry;
467 });
467 .filter((activity) => !isPlanBoundaryToolActivity(activity))
468 .map(toDerivedWorkLogEntry);
469 return collapseDerivedWorkLogEntries(entries).map(
470 ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
471 );
472}
473
474function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
475 if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
476 return false;
477 }
478
479 const payload =
480 activity.payload && typeof activity.payload === "object"
481 ? (activity.payload as Record<string, unknown>)
482 : null;
483 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
484}
485
486function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
487 const payload =
488 activity.payload && typeof activity.payload === "object"
489 ? (activity.payload as Record<string, unknown>)
490 : null;
491 const command = extractToolCommand(payload);
492 const changedFiles = extractChangedFiles(payload);
493 const title = extractToolTitle(payload);
494 const entry: DerivedWorkLogEntry = {
495 id: activity.id,
496 createdAt: activity.createdAt,
497 label: activity.summary,
498 tone: activity.tone === "approval" ? "info" : activity.tone,
499 activityKind: activity.kind,
500 };
501 const itemType = extractWorkLogItemType(payload);
502 const requestKind = extractWorkLogRequestKind(payload);
503 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
504 const detail = stripTrailingExitCode(payload.detail).output;
505 if (detail) {
506 entry.detail = detail;
507 }
508 }
509 if (command) {
510 entry.command = command;
511 }
512 if (changedFiles.length > 0) {
513 entry.changedFiles = changedFiles;
514 }
515 if (title) {
516 entry.toolTitle = title;
517 }
518 if (itemType) {
519 entry.itemType = itemType;
520 }
521 if (requestKind) {
522 entry.requestKind = requestKind;
523 }
524 const collapseKey = deriveToolLifecycleCollapseKey(entry);
525 if (collapseKey) {
526 entry.collapseKey = collapseKey;
527 }
528 return entry;
529}
530
531function collapseDerivedWorkLogEntries(
532 entries: ReadonlyArray<DerivedWorkLogEntry>,
533): DerivedWorkLogEntry[] {
534 const collapsed: DerivedWorkLogEntry[] = [];
535 for (const entry of entries) {
536 const previous = collapsed.at(-1);
537 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
538 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
539 continue;
540 }
541 collapsed.push(entry);
542 }
543 return collapsed;
544}
545
546function shouldCollapseToolLifecycleEntries(
547 previous: DerivedWorkLogEntry,
548 next: DerivedWorkLogEntry,
549): boolean {
550 if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
551 return false;
552 }
553 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
554 return false;
555 }
556 if (previous.activityKind === "tool.completed") {
557 return false;
558 }
559 return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;
560}
561
562function mergeDerivedWorkLogEntries(
563 previous: DerivedWorkLogEntry,
564 next: DerivedWorkLogEntry,
565): DerivedWorkLogEntry {
566 const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles);
567 const detail = next.detail ?? previous.detail;
568 const command = next.command ?? previous.command;
569 const toolTitle = next.toolTitle ?? previous.toolTitle;
570 const itemType = next.itemType ?? previous.itemType;
571 const requestKind = next.requestKind ?? previous.requestKind;
572 const collapseKey = next.collapseKey ?? previous.collapseKey;
573 return {
574 ...previous,
575 ...next,
576 ...(detail ? { detail } : {}),
577 ...(command ? { command } : {}),
578 ...(changedFiles.length > 0 ? { changedFiles } : {}),
579 ...(toolTitle ? { toolTitle } : {}),
580 ...(itemType ? { itemType } : {}),
581 ...(requestKind ? { requestKind } : {}),
582 ...(collapseKey ? { collapseKey } : {}),
583 };
584}
585
586function mergeChangedFiles(
587 previous: ReadonlyArray<string> | undefined,
588 next: ReadonlyArray<string> | undefined,
589): string[] {
590 const merged = [...(previous ?? []), ...(next ?? [])];
591 if (merged.length === 0) {
592 return [];
593 }
594 return [...new Set(merged)];
595}
596
597function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
598 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
599 return undefined;
600 }
601 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
602 const detail = entry.detail?.trim() ?? "";
603 const itemType = entry.itemType ?? "";
604 if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) {
605 return undefined;
606 }
607 return [itemType, normalizedLabel, detail].join("\u001f");
608}
609
610function normalizeCompactToolLabel(value: string): string {
611 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
612}
613
614function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPlanState {
615 return {
616 id: proposedPlan.id,
617 createdAt: proposedPlan.createdAt,
618 updatedAt: proposedPlan.updatedAt,
619 turnId: proposedPlan.turnId,
620 planMarkdown: proposedPlan.planMarkdown,
621 implementedAt: proposedPlan.implementedAt,
622 implementationThreadId: proposedPlan.implementationThreadId,
623 };
468624}
469625
470626function asRecord(value: unknown): Record<string, unknown> | null {
......@@ -632,7 +788,31 @@ function compareActivitiesByOrder(
632788 return -1;
633789 }
634790
635 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
791 const createdAtComparison = left.createdAt.localeCompare(right.createdAt);
792 if (createdAtComparison !== 0) {
793 return createdAtComparison;
794 }
795
796 const lifecycleRankComparison =
797 compareActivityLifecycleRank(left.kind) - compareActivityLifecycleRank(right.kind);
798 if (lifecycleRankComparison !== 0) {
799 return lifecycleRankComparison;
800 }
801
802 return left.id.localeCompare(right.id);
803}
804
805function compareActivityLifecycleRank(kind: string): number {
806 if (kind.endsWith(".started") || kind === "tool.started") {
807 return 0;
808 }
809 if (kind.endsWith(".progress") || kind.endsWith(".updated")) {
810 return 1;
811 }
812 if (kind.endsWith(".completed") || kind.endsWith(".resolved")) {
813 return 2;
814 }
815 return 1;
636816}
637817
638818export function hasToolActivityForTurn(
apps/web/src/store.test.ts+24-2
......@@ -191,7 +191,7 @@ describe("store pure functions", () => {
191191});
192192
193193describe("store read model sync", () => {
194 it("falls back to the codex default for unsupported provider models without an active session", () => {
194 it("preserves claude model slugs without an active session", () => {
195195 const initialState = makeState(makeThread());
196196 const readModel = makeReadModel(
197197 makeReadModelThread({
......@@ -201,7 +201,29 @@ describe("store read model sync", () => {
201201
202202 const next = syncServerReadModel(initialState, readModel);
203203
204 expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
204 expect(next.threads[0]?.model).toBe("claude-opus-4-6");
205 });
206
207 it("resolves claude aliases when session provider is claudeAgent", () => {
208 const initialState = makeState(makeThread());
209 const readModel = makeReadModel(
210 makeReadModelThread({
211 model: "sonnet",
212 session: {
213 threadId: ThreadId.makeUnsafe("thread-1"),
214 status: "ready",
215 providerName: "claudeAgent",
216 runtimeMode: "approval-required",
217 activeTurnId: null,
218 lastError: null,
219 updatedAt: "2026-02-27T00:00:00.000Z",
220 },
221 }),
222 );
223
224 const next = syncServerReadModel(initialState, readModel);
225
226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
205227 });
206228
207229 it("preserves the current project order when syncing incoming read model updates", () => {
apps/web/src/store.ts+4-11
......@@ -7,8 +7,7 @@ import {
77 type OrchestrationSessionStatus,
88} from "@t3tools/contracts";
99import {
10 getModelOptions,
11 normalizeModelSlug,
10 inferProviderForModel,
1211 resolveModelSlug,
1312 resolveModelSlugForProvider,
1413} from "@t3tools/shared/model";
......@@ -189,26 +188,20 @@ function toLegacySessionStatus(
189188}
190189
191190function toLegacyProvider(providerName: string | null): ProviderKind {
192 if (providerName === "codex") {
191 if (providerName === "codex" || providerName === "claudeAgent") {
193192 return providerName;
194193 }
195194 return "codex";
196195}
197196
198const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));
199
200197function inferProviderForThreadModel(input: {
201198 readonly model: string;
202199 readonly sessionProviderName: string | null;
203200}): ProviderKind {
204 if (input.sessionProviderName === "codex") {
201 if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") {
205202 return input.sessionProviderName;
206203 }
207 const normalizedCodex = normalizeModelSlug(input.model, "codex");
208 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
209 return "codex";
210 }
211 return "codex";
204 return inferProviderForModel(input.model);
212205}
213206
214207function resolveWsHttpOrigin(): string {
apps/web/vitest.browser.config.ts+1-4
......@@ -15,10 +15,7 @@ export default mergeConfig(
1515 },
1616 },
1717 test: {
18 include: [
19 "src/components/ChatView.browser.tsx",
20 "src/components/KeybindingsToast.browser.tsx",
21 ],
18 include: ["src/components/**/*.browser.tsx"],
2219 browser: {
2320 enabled: true,
2421 provider: playwright(),
bun.lock+73-71
......@@ -48,6 +48,7 @@
4848 "t3": "./dist/index.mjs",
4949 },
5050 "dependencies": {
51 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
5152 "@effect/platform-node": "catalog:",
5253 "@effect/sql-sqlite-bun": "catalog:",
5354 "@pierre/diffs": "^1.1.0-beta.16",
......@@ -152,6 +153,7 @@
152153 "scripts": {
153154 "name": "@t3tools/scripts",
154155 "dependencies": {
156 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
155157 "@effect/platform-node": "catalog:",
156158 "@t3tools/contracts": "workspace:*",
157159 "@t3tools/shared": "workspace:*",
......@@ -182,13 +184,15 @@
182184 "vitest": "^4.0.0",
183185 },
184186 "packages": {
185 "@astrojs/check": ["@astrojs/check@0.9.7", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-dA7U5/OFg8/xaMUb2vUOOJuuJXnMpHy6F0BM8ZhL7WT5OkTBwJ0GoW38n4fC4CXt+lT9mLWL0y8Pa74tFByBpQ=="],
187 "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="],
186188
187 "@astrojs/compiler": ["@astrojs/compiler@3.0.0", "", {}, "sha512-MwAbDE5mawZ1SS+D8qWiHdprdME5Tlj2e0YjxnEICvcOpbSukNS7Sa7hA5PK+6RrmUr/t6Gi5YgrdZKjbO/WPQ=="],
189 "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="],
190
191 "@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="],
188192
189193 "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="],
190194
191 "@astrojs/language-server": ["@astrojs/language-server@2.16.4", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.68", "volar-service-emmet": "0.0.68", "volar-service-html": "0.0.68", "volar-service-prettier": "0.0.68", "volar-service-typescript": "0.0.68", "volar-service-typescript-twoslash-queries": "0.0.68", "volar-service-yaml": "0.0.68", "vscode-html-languageservice": "^5.6.1", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-42oqz9uX+hU1/rFniJvtYW9FbfZJ6syM2fYZFi7Ub71/kOvF1GSeMS8sA3Ogs3iOeNUWefk/ImwBiiHeNmJfSA=="],
195 "@astrojs/language-server": ["@astrojs/language-server@2.16.5", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w=="],
192196
193197 "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.0.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jTAXHPy45L7o1ljH4jYV+ShtOHtyQUa1mGp3a5fJp1soX8lInuTJQ6ihmldHzVM4Q7QptU4SzIDIcKbBJO7sXQ=="],
194198
......@@ -548,45 +552,45 @@
548552
549553 "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="],
550554
551 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ=="],
555 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="],
552556
553 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA=="],
557 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="],
554558
555 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA=="],
559 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="],
556560
557 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ=="],
561 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="],
558562
559 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw=="],
563 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="],
560564
561 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w=="],
565 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="],
562566
563 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w=="],
567 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="],
564568
565 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ=="],
569 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="],
566570
567 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ=="],
571 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="],
568572
569 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ=="],
573 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="],
570574
571 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q=="],
575 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="],
572576
573 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg=="],
577 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="],
574578
575 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q=="],
579 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="],
576580
577 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw=="],
581 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="],
578582
579 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA=="],
583 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="],
580584
581 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA=="],
585 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="],
582586
583 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw=="],
587 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="],
584588
585 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg=="],
589 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="],
586590
587 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ=="],
591 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="],
588592
589 "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.19", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-XxGPKkVW+1t2KJQfgjmSnS+93nI9+ACJl1XjhF3Lo4BdQJOxV3pHeyix31ySn/m/1llq6O/7bXucE0OYCK6Kog=="],
593 "@pierre/diffs": ["@pierre/diffs@1.1.0", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-wbxrzcmanJuHZb81iir09j42uU9AnKxXDtAuEQJbAnti5f2UfYdCQYejawuHZStFrlsMacCZLh/dDHmqvAaQCw=="],
590594
591595 "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
592596
......@@ -626,7 +630,7 @@
626630
627631 "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.3", "", { "os": "win32", "cpu": "x64" }, "sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A=="],
628632
629 "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.0", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-ZkajPtfcBTfLSZTmm40k2E0cwT5HDosBcj71fdUURXl7FfETb50jEbV39ujPw2RFp3ZpOugK3ATdX+HuFfWr9Q=="],
633 "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.1", "", { "dependencies": { "picomatch": "^4.0.3" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-pHDVHqFv26JNC8I500JZ0H4h1kvSyiE3V9gjEO9pRAgD1KrIdJvcHCokV6f7gG7Rx4vMOD11V8VUOpqdyGbKBw=="],
630634
631635 "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
632636
......@@ -700,7 +704,7 @@
700704
701705 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="],
702706
703 "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
707 "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
704708
705709 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
706710
......@@ -710,25 +714,25 @@
710714
711715 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
712716
713 "@tanstack/react-router": ["@tanstack/react-router@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw=="],
717 "@tanstack/react-router": ["@tanstack/react-router@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1qbSy4r+O7IBdmPLlcKsjB041Gq2MMnIEAYSGIjaMZIL4duUIQnOWLw4jTfjKil/IJz/9rO5JcvrbxOG5UTSdg=="],
714718
715719 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
716720
717 "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="],
721 "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="],
718722
719 "@tanstack/router-core": ["@tanstack/router-core@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg=="],
723 "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="],
720724
721 "@tanstack/router-generator": ["@tanstack/router-generator@1.166.7", "", { "dependencies": { "@tanstack/router-core": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-lBI0VS7J1zMrJhfvT+3FMq9jPdOrJ3VgciPXyYvZBF/a9Mr8T94MU78PqrBNuJbYh7qCFO14ZhArUFqkYGuozQ=="],
725 "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="],
722726
723 "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.166.7", "@tanstack/router-generator": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.166.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-R06qe5UwApb/u02wDITVxN++6QE4xsLFQCr029VZ+4V8gyIe35kr8UCg3Jiyl6D5GXxhj62U2Ei8jccdkQaivw=="],
727 "@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.3", "@tanstack/router-generator": "1.166.11", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-PYsnN6goK6zBaVo63UVKjofv69+HHMKRQXymwN55JYKguNnNR8OZ6E12icPb0Olc5uIpPiGz1YI2+rbpmNKGHA=="],
724728
725 "@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="],
729 "@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
726730
727731 "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
728732
729 "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="],
733 "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="],
730734
731 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="],
735 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="],
732736
733737 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
734738
......@@ -786,7 +790,7 @@
786790
787791 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
788792
789 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-Bu5/eP6td3WI654+tRq+ryW1PbgA90y5pqMKpb3U7UpNk6VjI53P/ncPUd192U9dSrepLy7DHnq1XEMDz5H++w=="],
793 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
790794
791795 "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="],
792796
......@@ -852,7 +856,7 @@
852856
853857 "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
854858
855 "astro": ["astro@6.0.4", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-1piLJCPTL/x7AMO2cjVFSTFyRqKuC3W8sSEySCt1aJio+p/wGs5H3K+Xr/rE9ftKtknLUtjxCqCE7/0NsXfGpQ=="],
859 "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="],
856860
857861 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
858862
......@@ -866,7 +870,7 @@
866870
867871 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
868872
869 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="],
873 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
870874
871875 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
872876
......@@ -896,7 +900,7 @@
896900
897901 "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
898902
899 "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
903 "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
900904
901905 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
902906
......@@ -1156,7 +1160,7 @@
11561160
11571161 "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
11581162
1159 "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
1163 "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
11601164
11611165 "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
11621166
......@@ -1282,7 +1286,7 @@
12821286
12831287 "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
12841288
1285 "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
1289 "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
12861290
12871291 "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
12881292
......@@ -1406,7 +1410,7 @@
14061410
14071411 "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
14081412
1409 "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="],
1413 "msw": ["msw@2.12.11", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-dVg20zi2I2EvnwH/+WupzsOC2mCa7qsIhyMAWtfRikn6RKtwL9+7SaF1IQ5LyZry4tlUtf6KyTVhnlQiZXozTQ=="],
14101414
14111415 "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
14121416
......@@ -1450,7 +1454,7 @@
14501454
14511455 "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
14521456
1453 "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
1457 "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
14541458
14551459 "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
14561460
......@@ -1458,7 +1462,7 @@
14581462
14591463 "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="],
14601464
1461 "oxlint": ["oxlint@1.55.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.55.0", "@oxlint/binding-android-arm64": "1.55.0", "@oxlint/binding-darwin-arm64": "1.55.0", "@oxlint/binding-darwin-x64": "1.55.0", "@oxlint/binding-freebsd-x64": "1.55.0", "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", "@oxlint/binding-linux-arm-musleabihf": "1.55.0", "@oxlint/binding-linux-arm64-gnu": "1.55.0", "@oxlint/binding-linux-arm64-musl": "1.55.0", "@oxlint/binding-linux-ppc64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-musl": "1.55.0", "@oxlint/binding-linux-s390x-gnu": "1.55.0", "@oxlint/binding-linux-x64-gnu": "1.55.0", "@oxlint/binding-linux-x64-musl": "1.55.0", "@oxlint/binding-openharmony-arm64": "1.55.0", "@oxlint/binding-win32-arm64-msvc": "1.55.0", "@oxlint/binding-win32-ia32-msvc": "1.55.0", "@oxlint/binding-win32-x64-msvc": "1.55.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg=="],
1465 "oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="],
14621466
14631467 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
14641468
......@@ -1510,7 +1514,7 @@
15101514
15111515 "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
15121516
1513 "pure-rand": ["pure-rand@8.0.0", "", {}, "sha512-7rgWlxG2gAvFPIQfUreo1XYlNvrQ9VnQPFWdncPkdl3icucLK0InOxsaafbvxGTnI6Bk/Rxmslg0lQlRCuzOXw=="],
1517 "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="],
15141518
15151519 "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
15161520
......@@ -1676,7 +1680,7 @@
16761680
16771681 "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="],
16781682
1679 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
1683 "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
16801684
16811685 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
16821686
......@@ -1684,9 +1688,9 @@
16841688
16851689 "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
16861690
1687 "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
1691 "tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="],
16881692
1689 "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
1693 "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="],
16901694
16911695 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
16921696
......@@ -1710,19 +1714,19 @@
17101714
17111715 "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
17121716
1713 "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="],
1717 "turbo": ["turbo@2.8.17", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.17", "turbo-darwin-arm64": "2.8.17", "turbo-linux-64": "2.8.17", "turbo-linux-arm64": "2.8.17", "turbo-windows-64": "2.8.17", "turbo-windows-arm64": "2.8.17" }, "bin": { "turbo": "bin/turbo" } }, "sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A=="],
17141718
1715 "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="],
1719 "turbo-darwin-64": ["turbo-darwin-64@2.8.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q=="],
17161720
1717 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="],
1721 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw=="],
17181722
1719 "turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="],
1723 "turbo-linux-64": ["turbo-linux-64@2.8.17", "", { "os": "linux", "cpu": "x64" }, "sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA=="],
17201724
1721 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="],
1725 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg=="],
17221726
1723 "turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="],
1727 "turbo-windows-64": ["turbo-windows-64@2.8.17", "", { "os": "win32", "cpu": "x64" }, "sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg=="],
17241728
1725 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="],
1729 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ=="],
17261730
17271731 "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
17281732
......@@ -1740,7 +1744,7 @@
17401744
17411745 "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
17421746
1743 "undici": ["undici@7.24.0", "", {}, "sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg=="],
1747 "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
17441748
17451749 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
17461750
......@@ -1794,21 +1798,21 @@
17941798
17951799 "vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
17961800
1797 "vitest-browser-react": ["vitest-browser-react@2.0.5", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YODQX8mHTJCyKNVYTWJrLEYrUtw+QfLl78owgvuE7C5ydgmGBq6v5s4jK2w6wdPhIZsN9PpV1rQbmAevWJjO9g=="],
1801 "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="],
17981802
1799 "volar-service-css": ["volar-service-css@0.0.68", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q=="],
1803 "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="],
18001804
1801 "volar-service-emmet": ["volar-service-emmet@0.0.68", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ=="],
1805 "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="],
18021806
1803 "volar-service-html": ["volar-service-html@0.0.68", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA=="],
1807 "volar-service-html": ["volar-service-html@0.0.70", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ=="],
18041808
1805 "volar-service-prettier": ["volar-service-prettier@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw=="],
1809 "volar-service-prettier": ["volar-service-prettier@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg=="],
18061810
1807 "volar-service-typescript": ["volar-service-typescript@0.0.68", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw=="],
1811 "volar-service-typescript": ["volar-service-typescript@0.0.70", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg=="],
18081812
1809 "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w=="],
1813 "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ=="],
18101814
1811 "volar-service-yaml": ["volar-service-yaml@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.19.2" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA=="],
1815 "volar-service-yaml": ["volar-service-yaml@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ=="],
18121816
18131817 "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="],
18141818
......@@ -1856,7 +1860,7 @@
18561860
18571861 "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
18581862
1859 "yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="],
1863 "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="],
18601864
18611865 "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
18621866
......@@ -1864,7 +1868,7 @@
18641868
18651869 "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
18661870
1867 "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
1871 "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="],
18681872
18691873 "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
18701874
......@@ -1872,7 +1876,7 @@
18721876
18731877 "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
18741878
1875 "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
1879 "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
18761880
18771881 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
18781882
......@@ -1898,13 +1902,13 @@
18981902
18991903 "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
19001904
1901 "@effect/platform-node/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
1905 "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
19021906
1903 "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
1907 "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
19041908
1905 "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
1909 "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
19061910
1907 "@effect/vitest/effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
1911 "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="],
19081912
19091913 "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
19101914
......@@ -1988,8 +1992,6 @@
19881992
19891993 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
19901994
1991 "yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
1992
19931995 "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
19941996
19951997 "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
packages/contracts/src/model.ts+44-8
......@@ -1,8 +1,11 @@
11import { Schema } from "effect";
2import { ProviderKind } from "./orchestration";
2import type { ProviderKind } from "./orchestration";
33
44export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
55export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];
6export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const;
7export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number];
8export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort;
69
710export const CodexModelOptions = Schema.Struct({
811 reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)),
......@@ -10,8 +13,16 @@ export const CodexModelOptions = Schema.Struct({
1013});
1114export type CodexModelOptions = typeof CodexModelOptions.Type;
1215
16export const ClaudeModelOptions = Schema.Struct({
17 thinking: Schema.optional(Schema.Boolean),
18 effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)),
19 fastMode: Schema.optional(Schema.Boolean),
20});
21export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
22
1323export const ProviderModelOptions = Schema.Struct({
1424 codex: Schema.optional(CodexModelOptions),
25 claudeAgent: Schema.optional(ClaudeModelOptions),
1526});
1627export type ProviderModelOptions = typeof ProviderModelOptions.Type;
1728
......@@ -29,19 +40,28 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
2940 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
3041 { slug: "gpt-5.2", name: "GPT-5.2" },
3142 ],
43 claudeAgent: [
44 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
45 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
46 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
47 ],
3248} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
3349export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;
3450
35type BuiltInModelSlug = ModelOptionsByProvider[ProviderKind][number]["slug"];
51type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
3652export type ModelSlug = BuiltInModelSlug | (string & {});
3753
38export const DEFAULT_MODEL_BY_PROVIDER = {
54export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
3955 codex: "gpt-5.4",
40} as const satisfies Record<ProviderKind, ModelSlug>;
56 claudeAgent: "claude-sonnet-4-6",
57};
4158
59// Backward compatibility for existing Codex-only call sites.
60export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
61export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
4262export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const;
4363
44export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
64export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
4565 codex: {
4666 "5.4": "gpt-5.4",
4767 "5.3": "gpt-5.3-codex",
......@@ -49,12 +69,28 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
4969 "5.3-spark": "gpt-5.3-codex-spark",
5070 "gpt-5.3-spark": "gpt-5.3-codex-spark",
5171 },
52} as const satisfies Record<ProviderKind, Record<string, ModelSlug>>;
72 claudeAgent: {
73 opus: "claude-opus-4-6",
74 "opus-4.6": "claude-opus-4-6",
75 "claude-opus-4.6": "claude-opus-4-6",
76 "claude-opus-4-6-20251117": "claude-opus-4-6",
77 sonnet: "claude-sonnet-4-6",
78 "sonnet-4.6": "claude-sonnet-4-6",
79 "claude-sonnet-4.6": "claude-sonnet-4-6",
80 "claude-sonnet-4-6-20251117": "claude-sonnet-4-6",
81 haiku: "claude-haiku-4-5",
82 "haiku-4.5": "claude-haiku-4-5",
83 "claude-haiku-4.5": "claude-haiku-4-5",
84 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
85 },
86};
5387
5488export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
5589 codex: CODEX_REASONING_EFFORT_OPTIONS,
56} as const satisfies Record<ProviderKind, readonly CodexReasoningEffort[]>;
90 claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS,
91} as const satisfies Record<ProviderKind, readonly ProviderReasoningEffort[]>;
5792
5893export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
5994 codex: "high",
60} as const satisfies Record<ProviderKind, CodexReasoningEffort | null>;
95 claudeAgent: "high",
96} as const satisfies Record<ProviderKind, ProviderReasoningEffort>;
packages/contracts/src/orchestration.test.ts+23
......@@ -6,6 +6,7 @@ import {
66 DEFAULT_PROVIDER_INTERACTION_MODE,
77 DEFAULT_RUNTIME_MODE,
88 OrchestrationGetTurnDiffInput,
9 OrchestrationLatestTurn,
910 OrchestrationProposedPlan,
1011 OrchestrationSession,
1112 ProjectCreateCommand,
......@@ -22,6 +23,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC
2223const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
2324 ThreadTurnStartRequestedPayload,
2425);
26const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn);
2527const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
2628const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
2729const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
......@@ -247,6 +249,27 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe
247249 }),
248250);
249251
252it.effect("decodes latest turn source proposed plan metadata when present", () =>
253 Effect.gen(function* () {
254 const parsed = yield* decodeOrchestrationLatestTurn({
255 turnId: "turn-2",
256 state: "running",
257 requestedAt: "2026-01-01T00:00:00.000Z",
258 startedAt: "2026-01-01T00:00:01.000Z",
259 completedAt: null,
260 assistantMessageId: null,
261 sourceProposedPlan: {
262 threadId: "thread-1",
263 planId: "plan-1",
264 },
265 });
266 assert.deepStrictEqual(parsed.sourceProposedPlan, {
267 threadId: "thread-1",
268 planId: "plan-1",
269 });
270 }),
271);
272
250273it.effect("decodes orchestration session runtime mode defaults", () =>
251274 Effect.gen(function* () {
252275 const parsed = yield* decodeOrchestrationSession({
packages/contracts/src/orchestration.ts+15-3
......@@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = {
2727 domainEvent: "orchestration.domainEvent",
2828} as const;
2929
30export const ProviderKind = Schema.Literal("codex");
30export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);
3131export type ProviderKind = typeof ProviderKind.Type;
3232export const ProviderApprovalPolicy = Schema.Literals([
3333 "untrusted",
......@@ -43,13 +43,24 @@ export const ProviderSandboxMode = Schema.Literals([
4343]);
4444export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
4545export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
46const CodexProviderStartOptions = Schema.Struct({
46
47export const CodexProviderStartOptions = Schema.Struct({
4748 binaryPath: Schema.optional(TrimmedNonEmptyString),
4849 homePath: Schema.optional(TrimmedNonEmptyString),
4950});
50const ProviderStartOptions = Schema.Struct({
51
52export const ClaudeProviderStartOptions = Schema.Struct({
53 binaryPath: Schema.optional(TrimmedNonEmptyString),
54 permissionMode: Schema.optional(TrimmedNonEmptyString),
55 maxThinkingTokens: Schema.optional(NonNegativeInt),
56});
57
58export const ProviderStartOptions = Schema.Struct({
5159 codex: Schema.optional(CodexProviderStartOptions),
60 claudeAgent: Schema.optional(ClaudeProviderStartOptions),
5261});
62export type ProviderStartOptions = typeof ProviderStartOptions.Type;
63
5364export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
5465export type RuntimeMode = typeof RuntimeMode.Type;
5566export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
......@@ -254,6 +265,7 @@ export const OrchestrationLatestTurn = Schema.Struct({
254265 startedAt: Schema.NullOr(IsoDateTime),
255266 completedAt: Schema.NullOr(IsoDateTime),
256267 assistantMessageId: Schema.NullOr(MessageId),
268 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
257269});
258270export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
259271
packages/contracts/src/provider.test.ts+48
......@@ -42,6 +42,38 @@ describe("ProviderSessionStartInput", () => {
4242 }),
4343 ).toThrow();
4444 });
45
46 it("accepts claude runtime knobs", () => {
47 const parsed = decodeProviderSessionStartInput({
48 threadId: "thread-1",
49 provider: "claudeAgent",
50 cwd: "/tmp/workspace",
51 model: "claude-sonnet-4-6",
52 modelOptions: {
53 claudeAgent: {
54 thinking: true,
55 effort: "max",
56 fastMode: true,
57 },
58 },
59 providerOptions: {
60 claudeAgent: {
61 binaryPath: "/usr/local/bin/claude",
62 permissionMode: "plan",
63 maxThinkingTokens: 12_000,
64 },
65 },
66 runtimeMode: "full-access",
67 });
68 expect(parsed.provider).toBe("claudeAgent");
69 expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true);
70 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max");
71 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
72 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");
73 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");
74 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);
75 expect(parsed.runtimeMode).toBe("full-access");
76 });
4577});
4678
4779describe("ProviderSendTurnInput", () => {
......@@ -61,4 +93,20 @@ describe("ProviderSendTurnInput", () => {
6193 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");
6294 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
6395 });
96
97 it("accepts claude provider effort options including ultrathink", () => {
98 const parsed = decodeProviderSendTurnInput({
99 threadId: "thread-1",
100 model: "claude-sonnet-4-6",
101 modelOptions: {
102 claudeAgent: {
103 effort: "ultrathink",
104 fastMode: true,
105 },
106 },
107 });
108
109 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink");
110 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
111 });
64112});
packages/contracts/src/provider.ts+10-20
......@@ -19,11 +19,11 @@ import {
1919 ProviderKind,
2020 ProviderRequestKind,
2121 ProviderSandboxMode,
22 ProviderStartOptions,
2223 ProviderUserInputAnswers,
2324 RuntimeMode,
2425} from "./orchestration";
2526
26const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
2727const ProviderSessionStatus = Schema.Literals([
2828 "connecting",
2929 "ready",
......@@ -36,32 +36,22 @@ export const ProviderSession = Schema.Struct({
3636 provider: ProviderKind,
3737 status: ProviderSessionStatus,
3838 runtimeMode: RuntimeMode,
39 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
40 model: Schema.optional(TrimmedNonEmptyStringSchema),
39 cwd: Schema.optional(TrimmedNonEmptyString),
40 model: Schema.optional(TrimmedNonEmptyString),
4141 threadId: ThreadId,
4242 resumeCursor: Schema.optional(Schema.Unknown),
4343 activeTurnId: Schema.optional(TurnId),
4444 createdAt: IsoDateTime,
4545 updatedAt: IsoDateTime,
46 lastError: Schema.optional(TrimmedNonEmptyStringSchema),
46 lastError: Schema.optional(TrimmedNonEmptyString),
4747});
4848export type ProviderSession = typeof ProviderSession.Type;
4949
50const CodexProviderStartOptions = Schema.Struct({
51 binaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
52 homePath: Schema.optional(TrimmedNonEmptyStringSchema),
53});
54
55export const ProviderStartOptions = Schema.Struct({
56 codex: Schema.optional(CodexProviderStartOptions),
57});
58export type ProviderStartOptions = typeof ProviderStartOptions.Type;
59
6050export const ProviderSessionStartInput = Schema.Struct({
6151 threadId: ThreadId,
6252 provider: Schema.optional(ProviderKind),
63 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
64 model: Schema.optional(TrimmedNonEmptyStringSchema),
53 cwd: Schema.optional(TrimmedNonEmptyString),
54 model: Schema.optional(TrimmedNonEmptyString),
6555 modelOptions: Schema.optional(ProviderModelOptions),
6656 resumeCursor: Schema.optional(Schema.Unknown),
6757 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
......@@ -74,12 +64,12 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
7464export const ProviderSendTurnInput = Schema.Struct({
7565 threadId: ThreadId,
7666 input: Schema.optional(
77 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
67 TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
7868 ),
7969 attachments: Schema.optional(
8070 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
8171 ),
82 model: Schema.optional(TrimmedNonEmptyStringSchema),
72 model: Schema.optional(TrimmedNonEmptyString),
8373 modelOptions: Schema.optional(ProviderModelOptions),
8474 interactionMode: Schema.optional(ProviderInteractionMode),
8575});
......@@ -125,8 +115,8 @@ export const ProviderEvent = Schema.Struct({
125115 provider: ProviderKind,
126116 threadId: ThreadId,
127117 createdAt: IsoDateTime,
128 method: TrimmedNonEmptyStringSchema,
129 message: Schema.optional(TrimmedNonEmptyStringSchema),
118 method: TrimmedNonEmptyString,
119 message: Schema.optional(TrimmedNonEmptyString),
130120 turnId: Schema.optional(TurnId),
131121 itemId: Schema.optional(ProviderItemId),
132122 requestId: Schema.optional(ApprovalRequestId),
packages/contracts/src/providerRuntime.test.ts+3-3
......@@ -10,7 +10,7 @@ describe("ProviderRuntimeEvent", () => {
1010 const parsed = decodeRuntimeEvent({
1111 type: "turn.plan.updated",
1212 eventId: "event-1",
13 provider: "codex",
13 provider: "claudeAgent",
1414 sessionId: "runtime-session-1",
1515 createdAt: "2026-02-28T00:00:00.000Z",
1616 threadId: "thread-1",
......@@ -56,7 +56,7 @@ describe("ProviderRuntimeEvent", () => {
5656 const parsed = decodeRuntimeEvent({
5757 type: "user-input.requested",
5858 eventId: "event-2",
59 provider: "codex",
59 provider: "claudeAgent",
6060 sessionId: "runtime-session-2",
6161 createdAt: "2026-02-28T00:00:01.000Z",
6262 threadId: "thread-2",
......@@ -94,7 +94,7 @@ describe("ProviderRuntimeEvent", () => {
9494 const parsed = decodeRuntimeEvent({
9595 type: "user-input.resolved",
9696 eventId: "event-3",
97 provider: "codex",
97 provider: "claudeAgent",
9898 sessionId: "runtime-session-2",
9999 createdAt: "2026-02-28T00:00:02.000Z",
100100 threadId: "thread-2",
packages/contracts/src/providerRuntime.ts+7-1
......@@ -1,4 +1,4 @@
1import { Schema } from "effect";
1import { Option, Schema } from "effect";
22import {
33 EventId,
44 IsoDateTime,
......@@ -19,6 +19,8 @@ const RuntimeEventRawSource = Schema.Literals([
1919 "codex.app-server.notification",
2020 "codex.app-server.request",
2121 "codex.eventmsg",
22 "claude.sdk.message",
23 "claude.sdk.permission",
2224 "codex.sdk.thread-event",
2325]);
2426export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
......@@ -411,6 +413,9 @@ export const UserInputQuestion = Schema.Struct({
411413 header: TrimmedNonEmptyStringSchema,
412414 question: TrimmedNonEmptyStringSchema,
413415 options: Schema.Array(UserInputQuestionOption),
416 multiSelect: Schema.optional(Schema.Boolean).pipe(
417 Schema.withConstructorDefault(() => Option.some(false)),
418 ),
414419});
415420export type UserInputQuestion = typeof UserInputQuestion.Type;
416421
......@@ -434,6 +439,7 @@ export type TaskStartedPayload = typeof TaskStartedPayload.Type;
434439const TaskProgressPayload = Schema.Struct({
435440 taskId: RuntimeTaskId,
436441 description: TrimmedNonEmptyStringSchema,
442 summary: Schema.optional(TrimmedNonEmptyStringSchema),
437443 usage: Schema.optional(Schema.Unknown),
438444 lastToolName: Schema.optional(TrimmedNonEmptyStringSchema),
439445});
packages/shared/src/model.test.ts+222-10
......@@ -1,13 +1,33 @@
11import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER } from "@t3tools/contracts";
2import {
3 DEFAULT_MODEL,
4 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
6 MODEL_OPTIONS,
7 MODEL_OPTIONS_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
9} from "@t3tools/contracts";
310
411import {
12 applyClaudePromptEffortPrefix,
13 getEffectiveClaudeCodeEffort,
514 getDefaultModel,
615 getDefaultReasoningEffort,
716 getModelOptions,
817 getReasoningEffortOptions,
18 inferProviderForModel,
19 isClaudeUltrathinkPrompt,
20 normalizeClaudeModelOptions,
21 normalizeCodexModelOptions,
922 normalizeModelSlug,
23 resolveReasoningEffortForProvider,
1024 resolveModelSlug,
25 resolveModelSlugForProvider,
26 supportsClaudeAdaptiveReasoning,
27 supportsClaudeFastMode,
28 supportsClaudeMaxEffort,
29 supportsClaudeThinkingToggle,
30 supportsClaudeUltrathinkKeyword,
1131} from "./model";
1232
1333describe("normalizeModelSlug", () => {
......@@ -32,38 +52,230 @@ describe("normalizeModelSlug", () => {
3252 expect(normalizeModelSlug("toString")).toBe("toString");
3353 expect(normalizeModelSlug("constructor")).toBe("constructor");
3454 });
55
56 it("uses provider-specific aliases", () => {
57 expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6");
58 expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6");
59 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5");
60 });
3561});
3662
3763describe("resolveModelSlug", () => {
3864 it("returns default only when the model is missing", () => {
39 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
40 expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
65 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
66 expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL);
4167 });
4268
4369 it("preserves unknown custom models", () => {
44 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
45 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
70 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
71 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL);
4672 });
4773
4874 it("resolves only supported model options", () => {
49 for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) {
75 for (const model of MODEL_OPTIONS) {
5076 expect(resolveModelSlug(model.slug)).toBe(model.slug);
5177 }
5278 });
79
80 it("supports provider-aware resolution", () => {
81 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
82 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
83 );
84 expect(resolveModelSlugForProvider("claudeAgent", "sonnet")).toBe("claude-sonnet-4-6");
85 expect(resolveModelSlugForProvider("claudeAgent", "gpt-5.3-codex")).toBe(
86 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
87 );
88 });
89
5390 it("keeps codex defaults for backward compatibility", () => {
54 expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
55 expect(getModelOptions()).toEqual(MODEL_OPTIONS_BY_PROVIDER.codex);
91 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
92 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
93 expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent);
5694 });
5795});
5896
5997describe("getReasoningEffortOptions", () => {
6098 it("returns codex reasoning options for codex", () => {
61 expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]);
99 expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex);
100 });
101
102 it("returns claude effort options for Opus 4.6", () => {
103 expect(getReasoningEffortOptions("claudeAgent", "claude-opus-4-6")).toEqual([
104 "low",
105 "medium",
106 "high",
107 "max",
108 "ultrathink",
109 ]);
110 });
111
112 it("returns claude effort options for Sonnet 4.6", () => {
113 expect(getReasoningEffortOptions("claudeAgent", "claude-sonnet-4-6")).toEqual([
114 "low",
115 "medium",
116 "high",
117 "ultrathink",
118 ]);
119 });
120
121 it("returns no claude effort options for Haiku 4.5", () => {
122 expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]);
123 });
124});
125
126describe("inferProviderForModel", () => {
127 it("detects known provider model slugs", () => {
128 expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex");
129 expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent");
130 expect(inferProviderForModel("sonnet")).toBe("claudeAgent");
131 });
132
133 it("falls back when the model is unknown", () => {
134 expect(inferProviderForModel("custom/internal-model")).toBe("codex");
135 expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent");
136 });
137
138 it("treats claude-prefixed custom slugs as claude", () => {
139 expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent");
62140 });
63141});
64142
65143describe("getDefaultReasoningEffort", () => {
66144 it("returns provider-scoped defaults", () => {
67 expect(getDefaultReasoningEffort("codex")).toBe("high");
145 expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex);
146 expect(getDefaultReasoningEffort("claudeAgent")).toBe(
147 DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent,
148 );
149 });
150});
151
152describe("resolveReasoningEffortForProvider", () => {
153 it("accepts provider-scoped effort values", () => {
154 expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh");
155 expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink");
156 });
157
158 it("rejects effort values from the wrong provider", () => {
159 expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull();
160 expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull();
161 });
162});
163
164describe("applyClaudePromptEffortPrefix", () => {
165 it("prefixes ultrathink prompts exactly once", () => {
166 expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe(
167 "Ultrathink:\nInvestigate this",
168 );
169 expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe(
170 "Ultrathink:\nInvestigate this",
171 );
172 });
173
174 it("leaves non-ultrathink prompts unchanged", () => {
175 expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this");
176 });
177});
178
179describe("getEffectiveClaudeCodeEffort", () => {
180 it("does not persist ultrathink into Claude runtime configuration", () => {
181 expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull();
182 expect(getEffectiveClaudeCodeEffort("high")).toBe("high");
183 });
184
185 it("returns null when no claude effort is selected", () => {
186 expect(getEffectiveClaudeCodeEffort(null)).toBeNull();
187 expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull();
188 });
189});
190
191describe("normalizeCodexModelOptions", () => {
192 it("drops default-only codex options", () => {
193 expect(
194 normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }),
195 ).toBeUndefined();
196 });
197
198 it("preserves non-default codex options", () => {
199 expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({
200 reasoningEffort: "xhigh",
201 fastMode: true,
202 });
203 });
204});
205
206describe("normalizeClaudeModelOptions", () => {
207 it("drops unsupported fast mode and max effort for Sonnet", () => {
208 expect(
209 normalizeClaudeModelOptions("claude-sonnet-4-6", {
210 effort: "max",
211 fastMode: true,
212 }),
213 ).toBeUndefined();
214 });
215
216 it("keeps the Haiku thinking toggle and removes unsupported effort", () => {
217 expect(
218 normalizeClaudeModelOptions("claude-haiku-4-5", {
219 thinking: false,
220 effort: "high",
221 }),
222 ).toEqual({
223 thinking: false,
224 });
225 });
226});
227
228describe("supportsClaudeAdaptiveReasoning", () => {
229 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {
230 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);
231 expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true);
232 expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false);
233 expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false);
234 });
235});
236
237describe("supportsClaudeMaxEffort", () => {
238 it("only enables max effort for Opus 4.6", () => {
239 expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true);
240 expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false);
241 expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false);
242 expect(supportsClaudeMaxEffort(undefined)).toBe(false);
243 });
244});
245
246describe("supportsClaudeFastMode", () => {
247 it("only enables Claude fast mode for Opus 4.6", () => {
248 expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true);
249 expect(supportsClaudeFastMode("opus")).toBe(true);
250 expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false);
251 expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false);
252 expect(supportsClaudeFastMode(undefined)).toBe(false);
253 });
254});
255
256describe("supportsClaudeUltrathinkKeyword", () => {
257 it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => {
258 expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true);
259 expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true);
260 expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false);
261 });
262});
263
264describe("supportsClaudeThinkingToggle", () => {
265 it("only enables the Claude thinking toggle for Haiku 4.5", () => {
266 expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false);
267 expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false);
268 expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true);
269 expect(supportsClaudeThinkingToggle("haiku")).toBe(true);
270 expect(supportsClaudeThinkingToggle(undefined)).toBe(false);
271 });
272});
273
274describe("isClaudeUltrathinkPrompt", () => {
275 it("detects ultrathink prompts case-insensitively", () => {
276 expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true);
277 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
278 expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false);
279 expect(isClaudeUltrathinkPrompt(undefined)).toBe(false);
68280 });
69281});
packages/shared/src/model.ts+182-12
......@@ -1,19 +1,29 @@
11import {
2 CLAUDE_CODE_EFFORT_OPTIONS,
23 CODEX_REASONING_EFFORT_OPTIONS,
34 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
46 MODEL_OPTIONS_BY_PROVIDER,
57 MODEL_SLUG_ALIASES_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
9 type ClaudeModelOptions,
10 type ClaudeCodeEffort,
11 type CodexModelOptions,
612 type CodexReasoningEffort,
713 type ModelSlug,
14 type ProviderReasoningEffort,
815 type ProviderKind,
916} from "@t3tools/contracts";
1017
11type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER;
12
13const MODEL_SLUG_SET_BY_PROVIDER: Record<CatalogProvider, ReadonlySet<ModelSlug>> = {
18const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
19 claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)),
1420 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
1521};
1622
23const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6";
24const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6";
25const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";
26
1727export function getModelOptions(provider: ProviderKind = "codex") {
1828 return MODEL_OPTIONS_BY_PROVIDER[provider];
1929}
......@@ -22,6 +32,31 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
2232 return DEFAULT_MODEL_BY_PROVIDER[provider];
2333}
2434
35export function supportsClaudeFastMode(model: string | null | undefined): boolean {
36 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;
37}
38
39export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean {
40 const normalized = normalizeModelSlug(model, "claudeAgent");
41 return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL;
42}
43
44export function supportsClaudeMaxEffort(model: string | null | undefined): boolean {
45 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;
46}
47
48export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean {
49 return supportsClaudeAdaptiveReasoning(model);
50}
51
52export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean {
53 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL;
54}
55
56export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
57 return typeof text === "string" && /\bultrathink\b/i.test(text);
58}
59
2560export function normalizeModelSlug(
2661 model: string | null | undefined,
2762 provider: ProviderKind = "codex",
......@@ -36,7 +71,9 @@ export function normalizeModelSlug(
3671 }
3772
3873 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, ModelSlug>;
39 const aliased = aliases[trimmed];
74 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
75 ? aliases[trimmed]
76 : undefined;
4077 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);
4178}
4279
......@@ -46,12 +83,12 @@ export function resolveModelSlug(
4683): ModelSlug {
4784 const normalized = normalizeModelSlug(model, provider);
4885 if (!normalized) {
49 return getDefaultModel(provider);
86 return DEFAULT_MODEL_BY_PROVIDER[provider];
5087 }
5188
5289 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
5390 ? normalized
54 : getDefaultModel(provider);
91 : DEFAULT_MODEL_BY_PROVIDER[provider];
5592}
5693
5794export function resolveModelSlugForProvider(
......@@ -61,18 +98,151 @@ export function resolveModelSlugForProvider(
6198 return resolveModelSlug(model, provider);
6299}
63100
101export function inferProviderForModel(
102 model: string | null | undefined,
103 fallback: ProviderKind = "codex",
104): ProviderKind {
105 const normalizedClaude = normalizeModelSlug(model, "claudeAgent");
106 if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) {
107 return "claudeAgent";
108 }
109
110 const normalizedCodex = normalizeModelSlug(model, "codex");
111 if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) {
112 return "codex";
113 }
114
115 return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback;
116}
117
118export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray<CodexReasoningEffort>;
119export function getReasoningEffortOptions(
120 provider: "claudeAgent",
121 model?: string | null | undefined,
122): ReadonlyArray<ClaudeCodeEffort>;
123export function getReasoningEffortOptions(
124 provider?: ProviderKind,
125 model?: string | null | undefined,
126): ReadonlyArray<ProviderReasoningEffort>;
64127export function getReasoningEffortOptions(
65128 provider: ProviderKind = "codex",
66): ReadonlyArray<CodexReasoningEffort> {
67 return provider === "codex" ? CODEX_REASONING_EFFORT_OPTIONS : [];
129 model?: string | null | undefined,
130): ReadonlyArray<ProviderReasoningEffort> {
131 if (provider === "claudeAgent") {
132 if (supportsClaudeMaxEffort(model)) {
133 return ["low", "medium", "high", "max", "ultrathink"];
134 }
135 if (supportsClaudeAdaptiveReasoning(model)) {
136 return ["low", "medium", "high", "ultrathink"];
137 }
138 return [];
139 }
140 return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider];
68141}
69142
70143export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort;
71export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null;
144export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort;
145export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort;
72146export function getDefaultReasoningEffort(
73147 provider: ProviderKind = "codex",
74): CodexReasoningEffort | null {
75 return provider === "codex" ? "high" : null;
148): ProviderReasoningEffort {
149 return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider];
150}
151
152export function resolveReasoningEffortForProvider(
153 provider: "codex",
154 effort: string | null | undefined,
155): CodexReasoningEffort | null;
156export function resolveReasoningEffortForProvider(
157 provider: "claudeAgent",
158 effort: string | null | undefined,
159): ClaudeCodeEffort | null;
160export function resolveReasoningEffortForProvider(
161 provider: ProviderKind,
162 effort: string | null | undefined,
163): ProviderReasoningEffort | null;
164export function resolveReasoningEffortForProvider(
165 provider: ProviderKind,
166 effort: string | null | undefined,
167): ProviderReasoningEffort | null {
168 if (typeof effort !== "string") {
169 return null;
170 }
171
172 const trimmed = effort.trim();
173 if (!trimmed) {
174 return null;
175 }
176
177 const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray<string>;
178 return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null;
179}
180
181export function getEffectiveClaudeCodeEffort(
182 effort: ClaudeCodeEffort | null | undefined,
183): Exclude<ClaudeCodeEffort, "ultrathink"> | null {
184 if (!effort) {
185 return null;
186 }
187 return effort === "ultrathink" ? null : effort;
188}
189
190export function normalizeCodexModelOptions(
191 modelOptions: CodexModelOptions | null | undefined,
192): CodexModelOptions | undefined {
193 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
194 const reasoningEffort =
195 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??
196 defaultReasoningEffort;
197 const fastModeEnabled = modelOptions?.fastMode === true;
198 const nextOptions: CodexModelOptions = {
199 ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}),
200 ...(fastModeEnabled ? { fastMode: true } : {}),
201 };
202 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
203}
204
205export function normalizeClaudeModelOptions(
206 model: string | null | undefined,
207 modelOptions: ClaudeModelOptions | null | undefined,
208): ClaudeModelOptions | undefined {
209 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
210 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");
211 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);
212 const effort =
213 resolvedEffort &&
214 resolvedEffort !== "ultrathink" &&
215 reasoningOptions.includes(resolvedEffort) &&
216 resolvedEffort !== defaultReasoningEffort
217 ? resolvedEffort
218 : undefined;
219 const thinking =
220 supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined;
221 const fastMode =
222 supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined;
223 const nextOptions: ClaudeModelOptions = {
224 ...(thinking === false ? { thinking: false } : {}),
225 ...(effort ? { effort } : {}),
226 ...(fastMode ? { fastMode: true } : {}),
227 };
228 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
229}
230
231export function applyClaudePromptEffortPrefix(
232 text: string,
233 effort: ClaudeCodeEffort | null | undefined,
234): string {
235 const trimmed = text.trim();
236 if (!trimmed) {
237 return trimmed;
238 }
239 if (effort !== "ultrathink") {
240 return trimmed;
241 }
242 if (trimmed.startsWith("Ultrathink:")) {
243 return trimmed;
244 }
245 return `Ultrathink:\n${trimmed}`;
76246}
77247
78export { CODEX_REASONING_EFFORT_OPTIONS };
248export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };
scripts/claude-fast-mode-probe.ts created+129
......@@ -0,0 +1,129 @@
1import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
2
3type ProbeMode = "off" | "on";
4
5async function* emptyPrompt(): AsyncGenerator<never, void, void> {}
6
7function parseArgs(argv: ReadonlyArray<string>): {
8 readonly mode: ProbeMode | "both";
9 readonly model?: string;
10 readonly cwd?: string;
11 readonly prompt?: string;
12} {
13 let mode: ProbeMode | "both" = "both";
14 let model: string | undefined;
15 let cwd: string | undefined;
16 let prompt: string | undefined;
17
18 for (let index = 0; index < argv.length; index += 1) {
19 const value = argv[index];
20 if (!value) {
21 continue;
22 }
23 if (value === "--mode") {
24 const next = argv[index + 1];
25 if (next === "off" || next === "on" || next === "both") {
26 mode = next;
27 index += 1;
28 }
29 continue;
30 }
31 if (value === "--model") {
32 model = argv[index + 1];
33 index += 1;
34 continue;
35 }
36 if (value === "--cwd") {
37 cwd = argv[index + 1];
38 index += 1;
39 continue;
40 }
41 if (value === "--prompt") {
42 prompt = argv[index + 1] ?? prompt;
43 index += 1;
44 }
45 }
46
47 return {
48 mode,
49 ...(model ? { model } : {}),
50 ...(cwd ? { cwd } : {}),
51 ...(prompt ? { prompt } : {}),
52 };
53}
54
55async function runProbe(input: {
56 readonly mode: ProbeMode;
57 readonly model?: string;
58 readonly cwd?: string;
59 readonly prompt?: string;
60}): Promise<void> {
61 const messages = query({
62 prompt: input.prompt ?? emptyPrompt(),
63 options: {
64 ...(input.model ? { model: input.model } : {}),
65 ...(input.cwd ? { cwd: input.cwd } : {}),
66 persistSession: false,
67 tools: [],
68 permissionMode: "plan",
69 includePartialMessages: true,
70 ...(input.mode === "on" ? { settings: { fastMode: true } } : {}),
71 },
72 });
73
74 const summary = {
75 mode: input.mode,
76 initFastModeState: null as string | null,
77 resultFastModeState: null as string | null,
78 resultSubtype: null as string | null,
79 resultText: null as string | null,
80 };
81
82 const initialization = await messages.initializationResult();
83 summary.initFastModeState = initialization.fast_mode_state ?? null;
84
85 if (!input.prompt) {
86 messages.close();
87 console.log(JSON.stringify(summary, null, 2));
88 return;
89 }
90
91 for await (const message of messages) {
92 handleProbeMessage(message, summary);
93 }
94 console.log(JSON.stringify(summary, null, 2));
95}
96
97function handleProbeMessage(
98 message: SDKMessage,
99 summary: {
100 mode: ProbeMode;
101 initFastModeState: string | null;
102 resultFastModeState: string | null;
103 resultSubtype: string | null;
104 resultText: string | null;
105 },
106): void {
107 if (message.type !== "result") {
108 return;
109 }
110 summary.resultSubtype = message.subtype;
111 summary.resultFastModeState = message.fast_mode_state ?? null;
112 summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n");
113}
114
115async function main(): Promise<void> {
116 const args = parseArgs(process.argv.slice(2));
117 const modes = args.mode === "both" ? (["off", "on"] as const) : [args.mode];
118
119 for (const mode of modes) {
120 await runProbe({
121 mode,
122 ...(args.model ? { model: args.model } : {}),
123 ...(args.cwd ? { cwd: args.cwd } : {}),
124 ...(args.prompt ? { prompt: args.prompt } : {}),
125 });
126 }
127}
128
129await main();
scripts/claude-haiku-thinking-probe.ts created+261
......@@ -0,0 +1,261 @@
1import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
2
3type ProbeCase =
4 | "default"
5 | "effort-low"
6 | "effort-high"
7 | "settings-off"
8 | "settings-on"
9 | "thinking-disabled"
10 | "thinking-enabled";
11
12function parseArgs(argv: ReadonlyArray<string>): {
13 readonly model: string;
14 readonly cwd?: string;
15 readonly prompt: string;
16 readonly cases: ReadonlyArray<ProbeCase>;
17} {
18 let model = "claude-haiku-4-5";
19 let cwd: string | undefined;
20 let prompt =
21 "Think step by step about this before answering: what is 27 multiplied by 43? Return the final number only.";
22 let cases: ReadonlyArray<ProbeCase> = [
23 "default",
24 "effort-low",
25 "effort-high",
26 "settings-off",
27 "settings-on",
28 "thinking-disabled",
29 "thinking-enabled",
30 ];
31
32 for (let index = 0; index < argv.length; index += 1) {
33 const value = argv[index];
34 if (!value) {
35 continue;
36 }
37 if (value === "--model") {
38 model = argv[index + 1] ?? model;
39 index += 1;
40 continue;
41 }
42 if (value === "--cwd") {
43 cwd = argv[index + 1] ?? cwd;
44 index += 1;
45 continue;
46 }
47 if (value === "--prompt") {
48 prompt = argv[index + 1] ?? prompt;
49 index += 1;
50 continue;
51 }
52 if (value === "--cases") {
53 const next = argv[index + 1];
54 if (next) {
55 const requested = next
56 .split(",")
57 .map((entry) => entry.trim())
58 .filter(
59 (entry): entry is ProbeCase =>
60 entry === "default" ||
61 entry === "effort-low" ||
62 entry === "effort-high" ||
63 entry === "settings-off" ||
64 entry === "settings-on" ||
65 entry === "thinking-disabled" ||
66 entry === "thinking-enabled",
67 );
68 if (requested.length > 0) {
69 cases = requested;
70 }
71 }
72 index += 1;
73 }
74 }
75
76 return {
77 model,
78 prompt,
79 ...(cwd ? { cwd } : {}),
80 cases,
81 };
82}
83
84function caseOptions(caseName: ProbeCase): {
85 readonly label: ProbeCase;
86 readonly effort?: "low" | "high";
87 readonly settings?: { alwaysThinkingEnabled: boolean };
88 readonly thinking?: ThinkingConfig;
89} {
90 switch (caseName) {
91 case "effort-low":
92 return {
93 label: caseName,
94 effort: "low",
95 };
96 case "effort-high":
97 return {
98 label: caseName,
99 effort: "high",
100 };
101 case "settings-off":
102 return {
103 label: caseName,
104 settings: {
105 alwaysThinkingEnabled: false,
106 },
107 };
108 case "settings-on":
109 return {
110 label: caseName,
111 settings: {
112 alwaysThinkingEnabled: true,
113 },
114 };
115 case "thinking-disabled":
116 return {
117 label: caseName,
118 thinking: {
119 type: "disabled",
120 },
121 };
122 case "thinking-enabled":
123 return {
124 label: caseName,
125 thinking: {
126 type: "enabled",
127 budgetTokens: 1024,
128 },
129 };
130 default:
131 return {
132 label: caseName,
133 };
134 }
135}
136
137type ProbeSummary = {
138 readonly case: ProbeCase;
139 readonly model: string;
140 initModelInfo: {
141 value: string | null;
142 supportsEffort: boolean | null;
143 supportsAdaptiveThinking: boolean | null;
144 supportedEffortLevels: ReadonlyArray<string> | null;
145 } | null;
146 resultSubtype: string | null;
147 resultText: string | null;
148 assistantThinkingBlockCount: number;
149 streamThinkingEventTypes: Record<string, number>;
150 error: string | null;
151};
152
153async function runCase(input: {
154 readonly caseName: ProbeCase;
155 readonly model: string;
156 readonly cwd?: string;
157 readonly prompt: string;
158}): Promise<ProbeSummary> {
159 const caseConfig = caseOptions(input.caseName);
160 const summary: ProbeSummary = {
161 case: input.caseName,
162 model: input.model,
163 initModelInfo: null,
164 resultSubtype: null,
165 resultText: null,
166 assistantThinkingBlockCount: 0,
167 streamThinkingEventTypes: {},
168 error: null,
169 };
170
171 try {
172 const messages = query({
173 prompt: input.prompt,
174 options: {
175 model: input.model,
176 ...(input.cwd ? { cwd: input.cwd } : {}),
177 persistSession: false,
178 tools: [],
179 permissionMode: "bypassPermissions",
180 includePartialMessages: true,
181 maxTurns: 1,
182 ...(caseConfig.effort ? { effort: caseConfig.effort } : {}),
183 ...(caseConfig.settings ? { settings: caseConfig.settings } : {}),
184 ...(caseConfig.thinking ? { thinking: caseConfig.thinking } : {}),
185 },
186 });
187
188 const initialization = await messages.initializationResult();
189 const initModel =
190 initialization.models.find((candidate) => candidate.value === input.model) ??
191 initialization.models.find((candidate) => candidate.value.includes("haiku")) ??
192 null;
193 summary.initModelInfo = initModel
194 ? {
195 value: initModel.value,
196 supportsEffort: initModel.supportsEffort ?? null,
197 supportsAdaptiveThinking: initModel.supportsAdaptiveThinking ?? null,
198 supportedEffortLevels: initModel.supportedEffortLevels ?? null,
199 }
200 : null;
201
202 for await (const message of messages) {
203 handleMessage(summary, message);
204 }
205 } catch (error) {
206 summary.error = error instanceof Error ? error.message : String(error);
207 }
208
209 return summary;
210}
211
212function handleMessage(summary: ProbeSummary, message: SDKMessage): void {
213 if (message.type === "stream_event") {
214 const event = message.event as {
215 type?: string;
216 delta?: {
217 type?: string;
218 };
219 };
220 const eventType = event.delta?.type ?? event.type;
221 if (typeof eventType === "string" && eventType.includes("thinking")) {
222 summary.streamThinkingEventTypes[eventType] =
223 (summary.streamThinkingEventTypes[eventType] ?? 0) + 1;
224 }
225 return;
226 }
227
228 if (message.type === "assistant") {
229 for (const block of message.message.content) {
230 if (typeof block?.type === "string" && block.type.includes("thinking")) {
231 summary.assistantThinkingBlockCount += 1;
232 }
233 }
234 return;
235 }
236
237 if (message.type === "result") {
238 summary.resultSubtype = message.subtype;
239 summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n");
240 }
241}
242
243async function main(): Promise<void> {
244 const args = parseArgs(process.argv.slice(2));
245 const results: ProbeSummary[] = [];
246
247 for (const caseName of args.cases) {
248 results.push(
249 await runCase({
250 caseName,
251 model: args.model,
252 ...(args.cwd ? { cwd: args.cwd } : {}),
253 prompt: args.prompt,
254 }),
255 );
256 }
257
258 console.log(JSON.stringify(results, null, 2));
259}
260
261await main();
scripts/package.json+3
......@@ -4,10 +4,13 @@
44 "type": "module",
55 "scripts": {
66 "prepare": "effect-language-service patch",
7 "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts",
8 "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts",
79 "typecheck": "tsc --noEmit",
810 "test": "vitest run"
911 },
1012 "dependencies": {
13 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
1114 "@effect/platform-node": "catalog:",
1215 "@t3tools/contracts": "workspace:*",
1316 "@t3tools/shared": "workspace:*",