| author | |
| committer | |
| log | 77716b4ccb529b635730e30a4ca94f5affe9db24 |
| tree | 54a05359a778c6f0afcf59865c3ef0f42285102e |
| parent | fb18b2de16195623427ec11b0c06e3245e7a8903 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ||
| 5 | The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: | |
| 6 | ||
| 7 | 1. `orchestration.dispatchCommand` (client intent) | |
| 8 | 2. `OrchestrationEngine` (decide + persist + publish domain events) | |
| 9 | 3. `ProviderCommandReactor` (domain intent -> `ProviderService`) | |
| 10 | 4. `ProviderService` (adapter routing + canonical runtime stream) | |
| 11 | 5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) | |
| 12 | 6. `orchestration.domainEvent` (single push channel consumed by web) | |
| 13 | ||
| 14 | Claude 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 | ||
| 20 | 1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. | |
| 21 | 2. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed. | |
| 22 | 3. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`. | |
| 23 | 4. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`. | |
| 24 | 5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. | |
| 25 | 6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. | |
| 26 | ||
| 27 | --- | |
| 28 | ||
| 29 | ## Architecture target | |
| 30 | ||
| 31 | Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. | |
| 32 | ||
| 33 | Key decisions: | |
| 34 | ||
| 35 | 1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. | |
| 36 | 2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. | |
| 37 | 3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. | |
| 38 | 4. 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 | ||
| 46 | Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. | |
| 47 | ||
| 48 | Expected outcomes: | |
| 49 | ||
| 50 | 1. Introduce provider-scoped model lists (Codex + Claude). | |
| 51 | 2. Add helpers that resolve model by provider. | |
| 52 | 3. Preserve backwards compatibility for existing Codex defaults. | |
| 53 | ||
| 54 | ### 1.2 Turn-start provider intent | |
| 55 | ||
| 56 | Update `packages/contracts/src/orchestration.ts`: | |
| 57 | ||
| 58 | 1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. | |
| 59 | 2. Carry provider through `ThreadTurnStartRequestedPayload`. | |
| 60 | 3. Keep existing command valid when provider is omitted. | |
| 61 | ||
| 62 | This 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 | ||
| 66 | Update `packages/contracts/src/provider.ts`: | |
| 67 | ||
| 68 | 1. Move provider-specific start fields into typed `providerOptions`: | |
| 69 | - `providerOptions.codex` | |
| 70 | - `providerOptions.claudeCode` | |
| 71 | 2. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`. | |
| 72 | 3. Deprecate/remove `resumeThreadId` from the generic start contract. | |
| 73 | 4. Treat `resumeCursor` as adapter-owned opaque state. | |
| 74 | ||
| 75 | ### 1.4 Contract tests (completed) | |
| 76 | ||
| 77 | Update/add tests in `packages/contracts/src/*.test.ts` for: | |
| 78 | ||
| 79 | 1. New command payload shape. | |
| 80 | 2. Provider-aware model resolution behavior. | |
| 81 | 3. 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 | ||
| 89 | Create: | |
| 90 | ||
| 91 | 1. `apps/server/src/provider/Services/ClaudeAdapter.ts` | |
| 92 | 2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` | |
| 93 | ||
| 94 | Adapter must implement `ProviderAdapterShape<ProviderAdapterError>`. | |
| 95 | ||
| 96 | ### 2.1.a SDK dependency and baseline config | |
| 97 | ||
| 98 | Add server dependency: | |
| 99 | ||
| 100 | 1. `@anthropic-ai/claude-agent-sdk` | |
| 101 | ||
| 102 | Baseline adapter options to support from day one: | |
| 103 | ||
| 104 | 1. `cwd` | |
| 105 | 2. `model` | |
| 106 | 3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) | |
| 107 | 4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) | |
| 108 | 5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) | |
| 109 | 6. `resume` | |
| 110 | 7. `resumeSessionAt` | |
| 111 | 8. `includePartialMessages` | |
| 112 | 9. `canUseTool` | |
| 113 | 10. `hooks` | |
| 114 | 11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) | |
| 115 | ||
| 116 | ### 2.2 Claude runtime bridge | |
| 117 | ||
| 118 | Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. | |
| 119 | ||
| 120 | Required capabilities: | |
| 121 | ||
| 122 | 1. Long-lived session context per adapter session. | |
| 123 | 2. Multi-turn input queue. | |
| 124 | 3. Interrupt support. | |
| 125 | 4. Approval request/response bridge. | |
| 126 | 5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). | |
| 127 | ||
| 128 | #### 2.2.a Agent SDK details to preserve | |
| 129 | ||
| 130 | The adapter should explicitly rely on these SDK capabilities: | |
| 131 | ||
| 132 | 1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). | |
| 133 | 2. Multi-turn input is supported via async-iterable prompt input. | |
| 134 | 3. Tool approval decisions are provided via `canUseTool`. | |
| 135 | 4. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`. | |
| 136 | 5. 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 | |
| 141 | import { query } from "@anthropic-ai/claude-agent-sdk"; | |
| 142 | import { Effect } from "effect"; | |
| 143 | ||
| 144 | const 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 | ||
| 183 | Preferred when available in the pinned Effect version: | |
| 184 | ||
| 185 | ```ts | |
| 186 | const 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 | ||
| 198 | Portable fallback (already aligned with current server patterns): | |
| 199 | ||
| 200 | ```ts | |
| 201 | const 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 | ||
| 229 | Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. | |
| 230 | ||
| 231 | Initial mapping target: | |
| 232 | ||
| 233 | 1. assistant text deltas -> `content.delta` | |
| 234 | 2. final assistant text -> `item.completed` and/or `turn.completed` | |
| 235 | 3. approval requests -> `request.opened` | |
| 236 | 4. approval results -> `request.resolved` | |
| 237 | 5. system lifecycle -> `session.*`, `thread.*`, `turn.*` | |
| 238 | 6. errors -> `runtime.error` | |
| 239 | 7. plan/proposed-plan content when derivable | |
| 240 | ||
| 241 | Implementation note: | |
| 242 | ||
| 243 | 1. Keep raw Claude message on `raw` for debugging. | |
| 244 | 2. Prefer canonical item/request kinds over provider-native enums. | |
| 245 | 3. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently. | |
| 246 | ||
| 247 | ### 2.4 Resume cursor strategy | |
| 248 | ||
| 249 | Define Claude-owned opaque resume state, e.g.: | |
| 250 | ||
| 251 | ```ts | |
| 252 | interface ClaudeResumeCursor { | |
| 253 | readonly version: 1; | |
| 254 | readonly threadId?: string; | |
| 255 | readonly sessionAt?: string; | |
| 256 | } | |
| 257 | ``` | |
| 258 | ||
| 259 | Rules: | |
| 260 | ||
| 261 | 1. Serialize only adapter-owned state into `resumeCursor`. | |
| 262 | 2. Parse/validate only inside Claude adapter. | |
| 263 | 3. Store updated cursor when Claude runtime yields enough data to resume safely. | |
| 264 | 4. Never overload orchestration thread id as Claude thread id. | |
| 265 | ||
| 266 | ### 2.5 Interrupt and stop semantics | |
| 267 | ||
| 268 | Map orchestration stop/interrupt expectations onto SDK controls: | |
| 269 | ||
| 270 | 1. `interruptTurn()` -> active query interrupt. | |
| 271 | 2. `stopSession()` -> close session resources and prevent future sends. | |
| 272 | 3. `rollbackThread()` -> see Phase 4. | |
| 273 | ||
| 274 | --- | |
| 275 | ||
| 276 | ## Phase 3: Provider service and composition | |
| 277 | ||
| 278 | ### 3.1 Register Claude adapter | |
| 279 | ||
| 280 | Update provider registry layer to include Claude: | |
| 281 | ||
| 282 | 1. add `claudeCode` -> `ClaudeAdapter` | |
| 283 | 2. ensure `ProviderService.listProviderStatuses()` reports Claude availability | |
| 284 | ||
| 285 | ### 3.2 Persist provider binding | |
| 286 | ||
| 287 | Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. | |
| 288 | ||
| 289 | Required validation: | |
| 290 | ||
| 291 | 1. Claude bindings survive restart. | |
| 292 | 2. resume cursor remains opaque and round-trips untouched. | |
| 293 | 3. stopAll + restart can recover Claude sessions when possible. | |
| 294 | ||
| 295 | ### 3.3 Provider start routing | |
| 296 | ||
| 297 | Update `ProviderCommandReactor` / orchestration flow: | |
| 298 | ||
| 299 | 1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. | |
| 300 | 2. If a thread already has Claude session binding, reuse it. | |
| 301 | 3. If provider switches between Codex and Claude, explicitly stop/rebind before next send. | |
| 302 | ||
| 303 | --- | |
| 304 | ||
| 305 | ## Phase 4: Checkpoint and revert strategy | |
| 306 | ||
| 307 | Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. | |
| 308 | ||
| 309 | Pick one explicit strategy: | |
| 310 | ||
| 311 | ### Option A: provider-native rewind | |
| 312 | ||
| 313 | If SDK/runtime supports safe rewind: | |
| 314 | ||
| 315 | 1. implement in Claude adapter | |
| 316 | 2. keep `CheckpointReactor` unchanged | |
| 317 | ||
| 318 | ### Option B: session restart + state truncation shim | |
| 319 | ||
| 320 | If no native rewind exists: | |
| 321 | ||
| 322 | 1. 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 | |
| 326 | 2. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion. | |
| 327 | ||
| 328 | Whichever option is chosen: | |
| 329 | ||
| 330 | 1. behavior must be deterministic | |
| 331 | 2. checkpoint revert tests must pass under orchestration expectations | |
| 332 | 3. 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 | ||
| 340 | Update web state/UI: | |
| 341 | ||
| 342 | 1. allow choosing Claude as thread provider before first turn | |
| 343 | 2. show Claude model list from provider-aware model helpers | |
| 344 | 3. preserve existing Codex default behavior when provider omitted | |
| 345 | ||
| 346 | Likely touch points: | |
| 347 | ||
| 348 | 1. `apps/web/src/store.ts` | |
| 349 | 2. `apps/web/src/components/ChatView.tsx` | |
| 350 | 3. `apps/web/src/types.ts` | |
| 351 | 4. `packages/shared/src/model.ts` | |
| 352 | ||
| 353 | ### 5.2 Settings for Claude executable/options | |
| 354 | ||
| 355 | Add app settings if needed for: | |
| 356 | ||
| 357 | 1. Claude binary path | |
| 358 | 2. default permission mode | |
| 359 | 3. default max thinking tokens | |
| 360 | ||
| 361 | Do 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 | ||
| 365 | No new WS channel should be needed. Claude should appear through existing: | |
| 366 | ||
| 367 | 1. thread messages | |
| 368 | 2. activities/worklog | |
| 369 | 3. approvals | |
| 370 | 4. session state | |
| 371 | 5. checkpoints/diffs | |
| 372 | ||
| 373 | --- | |
| 374 | ||
| 375 | ## Phase 6: Testing strategy | |
| 376 | ||
| 377 | ### 6.1 Contract tests | |
| 378 | ||
| 379 | Cover: | |
| 380 | ||
| 381 | 1. provider-aware model schemas | |
| 382 | 2. provider field on turn-start command | |
| 383 | 3. provider-specific start options schema | |
| 384 | ||
| 385 | ### 6.2 Adapter layer tests | |
| 386 | ||
| 387 | Add `ClaudeAdapter.test.ts` covering: | |
| 388 | ||
| 389 | 1. session start | |
| 390 | 2. event mapping | |
| 391 | 3. approval bridge | |
| 392 | 4. resume cursor parse/serialize | |
| 393 | 5. interrupt behavior | |
| 394 | 6. rollback behavior or explicit unsupported error path | |
| 395 | ||
| 396 | Use 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 | ||
| 400 | Extend provider integration coverage so Claude is exercised through `ProviderService`: | |
| 401 | ||
| 402 | 1. start Claude session | |
| 403 | 2. send turn | |
| 404 | 3. receive canonical runtime events | |
| 405 | 4. restart/recover using persisted binding | |
| 406 | ||
| 407 | ### 6.4 Orchestration integration tests | |
| 408 | ||
| 409 | Add/extend integration tests around: | |
| 410 | ||
| 411 | 1. first-turn provider selection | |
| 412 | 2. Claude approval requests routed through orchestration | |
| 413 | 3. Claude runtime ingestion -> messages/activities/session updates | |
| 414 | 4. checkpoint revert behavior under Claude | |
| 415 | 5. stopAll/restart recovery | |
| 416 | ||
| 417 | These should validate real orchestration flows, not just adapter behavior. | |
| 418 | ||
| 419 | --- | |
| 420 | ||
| 421 | ## Phase 7: Rollout order | |
| 422 | ||
| 423 | Recommended implementation order: | |
| 424 | ||
| 425 | 1. contracts/provider-aware models | |
| 426 | 2. provider field on turn-start | |
| 427 | 3. Claude adapter skeleton + start/send/stream | |
| 428 | 4. canonical event mapping | |
| 429 | 5. provider registry/service wiring | |
| 430 | 6. orchestration recovery + checkpoint strategy | |
| 431 | 7. web provider/model picker | |
| 432 | 8. full integration tests | |
| 433 | ||
| 434 | --- | |
| 435 | ||
| 436 | ## Non-goals | |
| 437 | ||
| 438 | 1. Reintroducing provider-specific WS methods/channels. | |
| 439 | 2. Storing provider-native thread ids as orchestration ids. | |
| 440 | 3. Bypassing orchestration engine for Claude-specific UI flows. | |
| 441 | 4. Encoding Claude resume semantics outside adapter-owned `resumeCursor`. |
AGENTS.md+1-1| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | |
| 8 | 8 | ## Project Snapshot |
| 9 | 9 | |
| 10 | T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). | |
| 10 | T3 Code is a minimal web GUI for using coding agents like Codex and Claude. | |
| 11 | 11 | |
| 12 | 12 | This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. |
| 13 | 13 |
README.md+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | # T3 Code |
| 2 | 2 | |
| 3 | T3 Code is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. | |
| 3 | T3 Code is a minimal web GUI for coding agents (currently Codex and Claude, more coming soon). | |
| 4 | 4 | |
| 5 | 5 | ## How to use |
| 6 | 6 |
apps/server/integration/OrchestrationEngineHarness.integration.ts+2-1| ... | ... | @@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process"; |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import { |
| 8 | 8 | ApprovalRequestId, |
| 9 | ProviderKind, | |
| 9 | 10 | type OrchestrationEvent, |
| 10 | 11 | type OrchestrationThread, |
| 11 | 12 | } from "@t3tools/contracts"; |
| ... | ... | @@ -205,7 +206,7 @@ export interface OrchestrationIntegrationHarness { |
| 205 | 206 | } |
| 206 | 207 | |
| 207 | 208 | interface MakeOrchestrationIntegrationHarnessOptions { |
| 208 | readonly provider?: "codex"; | |
| 209 | readonly provider?: ProviderKind; | |
| 209 | 210 | readonly realCodex?: boolean; |
| 210 | 211 | } |
| 211 | 212 |
apps/server/integration/TestProviderAdapter.integration.ts+6-5| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | ProviderTurnStartResult, |
| 11 | 11 | ThreadId, |
| 12 | 12 | TurnId, |
| 13 | ProviderKind, | |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import { Effect, Queue, Stream } from "effect"; |
| 15 | 16 | |
| ... | ... | @@ -35,7 +36,7 @@ export interface TestTurnResponse { |
| 35 | 36 | export type FixtureProviderRuntimeEvent = { |
| 36 | 37 | readonly type: string; |
| 37 | 38 | readonly eventId: EventId; |
| 38 | readonly provider: "codex"; | |
| 39 | readonly provider: ProviderKind; | |
| 39 | 40 | readonly createdAt: string; |
| 40 | 41 | readonly threadId: string; |
| 41 | 42 | readonly turnId?: string | undefined; |
| ... | ... | @@ -177,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti |
| 177 | 178 | |
| 178 | 179 | export interface TestProviderAdapterHarness { |
| 179 | 180 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; |
| 180 | readonly provider: "codex"; | |
| 181 | readonly provider: ProviderKind; | |
| 181 | 182 | readonly queueTurnResponse: ( |
| 182 | 183 | threadId: ThreadId, |
| 183 | 184 | response: TestTurnResponse, |
| ... | ... | @@ -197,7 +198,7 @@ export interface TestProviderAdapterHarness { |
| 197 | 198 | } |
| 198 | 199 | |
| 199 | 200 | interface MakeTestProviderAdapterHarnessOptions { |
| 200 | readonly provider?: "codex"; | |
| 201 | readonly provider?: ProviderKind; | |
| 201 | 202 | } |
| 202 | 203 | |
| 203 | 204 | function nowIso(): string { |
| ... | ... | @@ -205,7 +206,7 @@ function nowIso(): string { |
| 205 | 206 | } |
| 206 | 207 | |
| 207 | 208 | function sessionNotFound( |
| 208 | provider: "codex", | |
| 209 | provider: ProviderKind, | |
| 209 | 210 | threadId: ThreadId, |
| 210 | 211 | ): ProviderAdapterSessionNotFoundError { |
| 211 | 212 | return new ProviderAdapterSessionNotFoundError({ |
| ... | ... | @@ -215,7 +216,7 @@ function sessionNotFound( |
| 215 | 216 | } |
| 216 | 217 | |
| 217 | 218 | function missingSessionEffect( |
| 218 | provider: "codex", | |
| 219 | provider: ProviderKind, | |
| 219 | 220 | threadId: ThreadId, |
| 220 | 221 | ): Effect.Effect<never, ProviderAdapterError> { |
| 221 | 222 | return Effect.fail(sessionNotFound(provider, threadId)); |
apps/server/integration/orchestrationEngine.integration.test.ts+424-3| ... | ... | @@ -5,9 +5,11 @@ import { |
| 5 | 5 | ApprovalRequestId, |
| 6 | 6 | CommandId, |
| 7 | 7 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 8 | DEFAULT_MODEL_BY_PROVIDER, | |
| 8 | 9 | EventId, |
| 9 | 10 | MessageId, |
| 10 | 11 | ProjectId, |
| 12 | ProviderKind, | |
| 11 | 13 | ThreadId, |
| 12 | 14 | } from "@t3tools/contracts"; |
| 13 | 15 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -36,7 +38,7 @@ const PROJECT_ID = asProjectId("project-1"); |
| 36 | 38 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); |
| 37 | 39 | const FIXTURE_TURN_ID = "fixture-turn"; |
| 38 | 40 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); |
| 39 | type IntegrationProvider = "codex"; | |
| 41 | type IntegrationProvider = ProviderKind; | |
| 40 | 42 | |
| 41 | 43 | function nowIso() { |
| 42 | 44 | return new Date().toISOString(); |
| ... | ... | @@ -105,6 +107,8 @@ function withRealCodexHarness<A, E>( |
| 105 | 107 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 106 | 108 | Effect.gen(function* () { |
| 107 | 109 | const createdAt = nowIso(); |
| 110 | const provider = harness.adapterHarness?.provider ?? "codex"; | |
| 111 | const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; | |
| 108 | 112 | |
| 109 | 113 | yield* harness.engine.dispatch({ |
| 110 | 114 | type: "project.create", |
| ... | ... | @@ -112,7 +116,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 112 | 116 | projectId: PROJECT_ID, |
| 113 | 117 | title: "Integration Project", |
| 114 | 118 | workspaceRoot: harness.workspaceDir, |
| 115 | defaultModel: "gpt-5-codex", | |
| 119 | defaultModel, | |
| 116 | 120 | createdAt, |
| 117 | 121 | }); |
| 118 | 122 | |
| ... | ... | @@ -122,7 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 122 | 126 | threadId: THREAD_ID, |
| 123 | 127 | projectId: PROJECT_ID, |
| 124 | 128 | title: "Integration Thread", |
| 125 | model: "gpt-5-codex", | |
| 129 | model: defaultModel, | |
| 126 | 130 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 127 | 131 | runtimeMode: "approval-required", |
| 128 | 132 | branch: null, |
| ... | ... | @@ -882,3 +886,420 @@ it.live( |
| 882 | 886 | }), |
| 883 | 887 | ), |
| 884 | 888 | ); |
| 889 | ||
| 890 | it.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 | ||
| 944 | it.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 | ||
| 1049 | it.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 | ||
| 1120 | it.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 | ||
| 1186 | it.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 @@ |
| 22 | 22 | "test": "vitest run" |
| 23 | 23 | }, |
| 24 | 24 | "dependencies": { |
| 25 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", | |
| 25 | 26 | "@effect/platform-node": "catalog:", |
| 26 | 27 | "@effect/sql-sqlite-bun": "catalog:", |
| 27 | 28 | "@pierre/diffs": "^1.1.0-beta.16", |
apps/server/src/codexAppServerManager.test.ts+187| ... | ... | @@ -37,6 +37,7 @@ function createSendTurnHarness() { |
| 37 | 37 | planType: null, |
| 38 | 38 | sparkEnabled: true, |
| 39 | 39 | }, |
| 40 | collabReceiverTurns: new Map(), | |
| 40 | 41 | }; |
| 41 | 42 | |
| 42 | 43 | const requireSession = vi |
| ... | ... | @@ -75,6 +76,7 @@ function createThreadControlHarness() { |
| 75 | 76 | createdAt: "2026-02-10T00:00:00.000Z", |
| 76 | 77 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 77 | 78 | }, |
| 79 | collabReceiverTurns: new Map(), | |
| 78 | 80 | }; |
| 79 | 81 | |
| 80 | 82 | const requireSession = vi |
| ... | ... | @@ -117,6 +119,7 @@ function createPendingUserInputHarness() { |
| 117 | 119 | }, |
| 118 | 120 | ], |
| 119 | 121 | ]), |
| 122 | collabReceiverTurns: new Map(), | |
| 120 | 123 | }; |
| 121 | 124 | |
| 122 | 125 | const requireSession = vi |
| ... | ... | @@ -135,6 +138,43 @@ function createPendingUserInputHarness() { |
| 135 | 138 | return { manager, context, requireSession, writeMessage, emitEvent }; |
| 136 | 139 | } |
| 137 | 140 | |
| 141 | function 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 | ||
| 138 | 178 | describe("classifyCodexStderrLine", () => { |
| 139 | 179 | it("ignores empty lines", () => { |
| 140 | 180 | expect(classifyCodexStderrLine(" ")).toBeNull(); |
| ... | ... | @@ -721,6 +761,7 @@ describe("respondToUserInput", () => { |
| 721 | 761 | }, |
| 722 | 762 | pendingApprovals: new Map(), |
| 723 | 763 | pendingUserInputs: new Map(), |
| 764 | collabReceiverTurns: new Map(), | |
| 724 | 765 | }; |
| 725 | 766 | type ApprovalRequestContext = { |
| 726 | 767 | session: typeof context.session; |
| ... | ... | @@ -748,6 +789,152 @@ describe("respondToUserInput", () => { |
| 748 | 789 | }); |
| 749 | 790 | }); |
| 750 | 791 | |
| 792 | describe("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 | ||
| 751 | 938 | describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { |
| 752 | 939 | it("keeps prior thread history when resuming with a changed runtime mode", async () => { |
| 753 | 940 | const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); |
apps/server/src/codexAppServerManager.ts+94-10| ... | ... | @@ -70,6 +70,7 @@ interface CodexSessionContext { |
| 70 | 70 | pending: Map<PendingRequestKey, PendingRequest>; |
| 71 | 71 | pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>; |
| 72 | 72 | pendingUserInputs: Map<ApprovalRequestId, PendingUserInputRequest>; |
| 73 | collabReceiverTurns: Map<string, TurnId>; | |
| 73 | 74 | nextRequestId: number; |
| 74 | 75 | stopping: boolean; |
| 75 | 76 | } |
| ... | ... | @@ -571,6 +572,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 571 | 572 | pending: new Map(), |
| 572 | 573 | pendingApprovals: new Map(), |
| 573 | 574 | pendingUserInputs: new Map(), |
| 575 | collabReceiverTurns: new Map(), | |
| 574 | 576 | nextRequestId: 1, |
| 575 | 577 | stopping: false, |
| 576 | 578 | }; |
| ... | ... | @@ -732,6 +734,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 732 | 734 | |
| 733 | 735 | async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> { |
| 734 | 736 | const context = this.requireSession(input.threadId); |
| 737 | context.collabReceiverTurns.clear(); | |
| 735 | 738 | |
| 736 | 739 | const turnInput: Array< |
| 737 | 740 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| ... | ... | @@ -1121,7 +1124,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1121 | 1124 | context: CodexSessionContext, |
| 1122 | 1125 | notification: JsonRpcNotification, |
| 1123 | 1126 | ): 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 | } | |
| 1125 | 1137 | const textDelta = |
| 1126 | 1138 | notification.method === "item/agentMessage/delta" |
| 1127 | 1139 | ? this.readString(notification.params, "delta") |
| ... | ... | @@ -1134,8 +1146,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1134 | 1146 | threadId: context.session.threadId, |
| 1135 | 1147 | createdAt: new Date().toISOString(), |
| 1136 | 1148 | 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 } : {}), | |
| 1139 | 1153 | textDelta, |
| 1140 | 1154 | payload: notification.params, |
| 1141 | 1155 | }); |
| ... | ... | @@ -1151,6 +1165,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1151 | 1165 | } |
| 1152 | 1166 | |
| 1153 | 1167 | if (notification.method === "turn/started") { |
| 1168 | if (isChildConversation) { | |
| 1169 | return; | |
| 1170 | } | |
| 1154 | 1171 | const turnId = toTurnId(this.readString(this.readObject(notification.params)?.turn, "id")); |
| 1155 | 1172 | this.updateSession(context, { |
| 1156 | 1173 | status: "running", |
| ... | ... | @@ -1160,6 +1177,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1160 | 1177 | } |
| 1161 | 1178 | |
| 1162 | 1179 | if (notification.method === "turn/completed") { |
| 1180 | if (isChildConversation) { | |
| 1181 | return; | |
| 1182 | } | |
| 1183 | context.collabReceiverTurns.clear(); | |
| 1163 | 1184 | const turn = this.readObject(notification.params, "turn"); |
| 1164 | 1185 | const status = this.readString(turn, "status"); |
| 1165 | 1186 | const errorMessage = this.readString(this.readObject(turn, "error"), "message"); |
| ... | ... | @@ -1172,6 +1193,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1172 | 1193 | } |
| 1173 | 1194 | |
| 1174 | 1195 | if (notification.method === "error") { |
| 1196 | if (isChildConversation) { | |
| 1197 | return; | |
| 1198 | } | |
| 1175 | 1199 | const message = this.readString(this.readObject(notification.params)?.error, "message"); |
| 1176 | 1200 | const willRetry = this.readBoolean(notification.params, "willRetry"); |
| 1177 | 1201 | |
| ... | ... | @@ -1183,7 +1207,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1183 | 1207 | } |
| 1184 | 1208 | |
| 1185 | 1209 | 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; | |
| 1187 | 1213 | const requestKind = this.requestKindForMethod(request.method); |
| 1188 | 1214 | let requestId: ApprovalRequestId | undefined; |
| 1189 | 1215 | if (requestKind) { |
| ... | ... | @@ -1199,8 +1225,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1199 | 1225 | : "item/fileChange/requestApproval", |
| 1200 | 1226 | requestKind, |
| 1201 | 1227 | 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 } : {}), | |
| 1204 | 1230 | }; |
| 1205 | 1231 | context.pendingApprovals.set(requestId, pendingRequest); |
| 1206 | 1232 | } |
| ... | ... | @@ -1211,8 +1237,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1211 | 1237 | requestId, |
| 1212 | 1238 | jsonRpcId: request.id, |
| 1213 | 1239 | 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 } : {}), | |
| 1216 | 1242 | }); |
| 1217 | 1243 | } |
| 1218 | 1244 | |
| ... | ... | @@ -1223,8 +1249,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1223 | 1249 | threadId: context.session.threadId, |
| 1224 | 1250 | createdAt: new Date().toISOString(), |
| 1225 | 1251 | method: request.method, |
| 1226 | turnId: route.turnId, | |
| 1227 | itemId: route.itemId, | |
| 1252 | ...(effectiveTurnId ? { turnId: effectiveTurnId } : {}), | |
| 1253 | ...(rawRoute.itemId ? { itemId: rawRoute.itemId } : {}), | |
| 1228 | 1254 | requestId, |
| 1229 | 1255 | requestKind, |
| 1230 | 1256 | payload: request.params, |
| ... | ... | @@ -1451,6 +1477,64 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 1451 | 1477 | return route; |
| 1452 | 1478 | } |
| 1453 | 1479 | |
| 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 | ||
| 1454 | 1538 | private readObject(value: unknown, key?: string): Record<string, unknown> | undefined { |
| 1455 | 1539 | const target = |
| 1456 | 1540 | key === undefined |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+134-3| ... | ... | @@ -3,7 +3,7 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { execFileSync } from "node:child_process"; |
| 5 | 5 | |
| 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 6 | import type { ProviderKind, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 7 | 7 | import { |
| 8 | 8 | CommandId, |
| 9 | 9 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| ... | ... | @@ -44,7 +44,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 44 | 44 | type LegacyProviderRuntimeEvent = { |
| 45 | 45 | readonly type: string; |
| 46 | 46 | readonly eventId: EventId; |
| 47 | readonly provider: "codex"; | |
| 47 | readonly provider: ProviderKind; | |
| 48 | 48 | readonly createdAt: string; |
| 49 | 49 | readonly threadId: ThreadId; |
| 50 | 50 | readonly turnId?: string | undefined; |
| ... | ... | @@ -235,6 +235,7 @@ describe("CheckpointReactor", () => { |
| 235 | 235 | readonly projectWorkspaceRoot?: string; |
| 236 | 236 | readonly threadWorktreePath?: string | null; |
| 237 | 237 | readonly providerSessionCwd?: string; |
| 238 | readonly providerName?: ProviderKind; | |
| 238 | 239 | }) { |
| 239 | 240 | const cwd = createGitRepository(); |
| 240 | 241 | tempDirs.push(cwd); |
| ... | ... | @@ -242,7 +243,7 @@ describe("CheckpointReactor", () => { |
| 242 | 243 | cwd, |
| 243 | 244 | options?.hasSession ?? true, |
| 244 | 245 | options?.providerSessionCwd ?? cwd, |
| 245 | "codex", | |
| 246 | options?.providerName ?? "codex", | |
| 246 | 247 | ); |
| 247 | 248 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 248 | 249 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -477,6 +478,67 @@ describe("CheckpointReactor", () => { |
| 477 | 478 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 478 | 479 | }); |
| 479 | 480 | |
| 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 | ||
| 480 | 542 | it("appends capture failure activity when turn diff summary cannot be derived", async () => { |
| 481 | 543 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); |
| 482 | 544 | const createdAt = new Date().toISOString(); |
| ... | ... | @@ -792,6 +854,75 @@ describe("CheckpointReactor", () => { |
| 792 | 854 | ).toBe(false); |
| 793 | 855 | }); |
| 794 | 856 | |
| 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 | ||
| 795 | 926 | it("processes consecutive revert requests with deterministic rollback sequencing", async () => { |
| 796 | 927 | const harness = await createHarness(); |
| 797 | 928 | 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 |
| 1729 | 1729 | const threadId = ThreadId.makeUnsafe("thread-restart"); |
| 1730 | 1730 | const turnId = TurnId.makeUnsafe("turn-restart"); |
| 1731 | 1731 | const messageId = MessageId.makeUnsafe("message-restart"); |
| 1732 | const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source"); | |
| 1733 | const sourcePlanId = "plan-source"; | |
| 1732 | 1734 | const turnStartedAt = "2026-02-26T14:00:00.000Z"; |
| 1733 | 1735 | const sessionSetAt = "2026-02-26T14:00:05.000Z"; |
| 1734 | 1736 | |
| ... | ... | @@ -1749,6 +1751,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 1749 | 1751 | payload: { |
| 1750 | 1752 | threadId, |
| 1751 | 1753 | messageId, |
| 1754 | sourceProposedPlan: { | |
| 1755 | threadId: sourcePlanThreadId, | |
| 1756 | planId: sourcePlanId, | |
| 1757 | }, | |
| 1752 | 1758 | runtimeMode: "approval-required", |
| 1753 | 1759 | createdAt: turnStartedAt, |
| 1754 | 1760 | }, |
| ... | ... | @@ -1800,11 +1806,15 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 1800 | 1806 | return yield* sql<{ |
| 1801 | 1807 | readonly turnId: string; |
| 1802 | 1808 | readonly userMessageId: string | null; |
| 1809 | readonly sourceProposedPlanThreadId: string | null; | |
| 1810 | readonly sourceProposedPlanId: string | null; | |
| 1803 | 1811 | readonly startedAt: string; |
| 1804 | 1812 | }>` |
| 1805 | 1813 | SELECT |
| 1806 | 1814 | turn_id AS "turnId", |
| 1807 | 1815 | pending_message_id AS "userMessageId", |
| 1816 | source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", | |
| 1817 | source_proposed_plan_id AS "sourceProposedPlanId", | |
| 1808 | 1818 | started_at AS "startedAt" |
| 1809 | 1819 | FROM projection_turns |
| 1810 | 1820 | WHERE turn_id = ${turnId} |
| ... | ... | @@ -1815,6 +1825,8 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 1815 | 1825 | { |
| 1816 | 1826 | turnId: "turn-restart", |
| 1817 | 1827 | userMessageId: "message-restart", |
| 1828 | sourceProposedPlanThreadId: "thread-plan-source", | |
| 1829 | sourceProposedPlanId: "plan-source", | |
| 1818 | 1830 | startedAt: turnStartedAt, |
| 1819 | 1831 | }, |
| 1820 | 1832 | ]); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+22| ... | ... | @@ -808,6 +808,16 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 808 | 808 | pendingMessageId: |
| 809 | 809 | existingTurn.value.pendingMessageId ?? |
| 810 | 810 | (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), | |
| 811 | 821 | startedAt: |
| 812 | 822 | existingTurn.value.startedAt ?? |
| 813 | 823 | (Option.isSome(pendingTurnStart) |
| ... | ... | @@ -826,6 +836,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 826 | 836 | pendingMessageId: Option.isSome(pendingTurnStart) |
| 827 | 837 | ? pendingTurnStart.value.messageId |
| 828 | 838 | : null, |
| 839 | sourceProposedPlanThreadId: Option.isSome(pendingTurnStart) | |
| 840 | ? pendingTurnStart.value.sourceProposedPlanThreadId | |
| 841 | : null, | |
| 842 | sourceProposedPlanId: Option.isSome(pendingTurnStart) | |
| 843 | ? pendingTurnStart.value.sourceProposedPlanId | |
| 844 | : null, | |
| 829 | 845 | assistantMessageId: null, |
| 830 | 846 | state: "running", |
| 831 | 847 | requestedAt: Option.isSome(pendingTurnStart) |
| ... | ... | @@ -879,6 +895,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 879 | 895 | turnId: event.payload.turnId, |
| 880 | 896 | threadId: event.payload.threadId, |
| 881 | 897 | pendingMessageId: null, |
| 898 | sourceProposedPlanThreadId: null, | |
| 899 | sourceProposedPlanId: null, | |
| 882 | 900 | assistantMessageId: event.payload.messageId, |
| 883 | 901 | state: event.payload.streaming ? "running" : "completed", |
| 884 | 902 | requestedAt: event.payload.createdAt, |
| ... | ... | @@ -914,6 +932,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 914 | 932 | turnId: event.payload.turnId, |
| 915 | 933 | threadId: event.payload.threadId, |
| 916 | 934 | pendingMessageId: null, |
| 935 | sourceProposedPlanThreadId: null, | |
| 936 | sourceProposedPlanId: null, | |
| 917 | 937 | assistantMessageId: null, |
| 918 | 938 | state: "interrupted", |
| 919 | 939 | requestedAt: event.payload.createdAt, |
| ... | ... | @@ -958,6 +978,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 958 | 978 | turnId: event.payload.turnId, |
| 959 | 979 | threadId: event.payload.threadId, |
| 960 | 980 | pendingMessageId: null, |
| 981 | sourceProposedPlanThreadId: null, | |
| 982 | sourceProposedPlanId: null, | |
| 961 | 983 | assistantMessageId: event.payload.assistantMessageId, |
| 962 | 984 | state: nextState, |
| 963 | 985 | requestedAt: event.payload.completedAt, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8| ... | ... | @@ -178,6 +178,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 178 | 178 | thread_id, |
| 179 | 179 | turn_id, |
| 180 | 180 | pending_message_id, |
| 181 | source_proposed_plan_thread_id, | |
| 182 | source_proposed_plan_id, | |
| 181 | 183 | assistant_message_id, |
| 182 | 184 | state, |
| 183 | 185 | requested_at, |
| ... | ... | @@ -192,6 +194,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 192 | 194 | 'thread-1', |
| 193 | 195 | 'turn-1', |
| 194 | 196 | NULL, |
| 197 | 'thread-1', | |
| 198 | 'plan-1', | |
| 195 | 199 | 'message-1', |
| 196 | 200 | 'completed', |
| 197 | 201 | '2026-02-24T00:00:08.000Z', |
| ... | ... | @@ -262,6 +266,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 262 | 266 | startedAt: "2026-02-24T00:00:08.000Z", |
| 263 | 267 | completedAt: "2026-02-24T00:00:08.000Z", |
| 264 | 268 | assistantMessageId: asMessageId("message-1"), |
| 269 | sourceProposedPlan: { | |
| 270 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 271 | planId: "plan-1", | |
| 272 | }, | |
| 265 | 273 | }, |
| 266 | 274 | createdAt: "2026-02-24T00:00:02.000Z", |
| 267 | 275 | updatedAt: "2026-02-24T00:00:03.000Z", |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+15-1| ... | ... | @@ -4,8 +4,10 @@ import { |
| 4 | 4 | MessageId, |
| 5 | 5 | NonNegativeInt, |
| 6 | 6 | OrchestrationCheckpointFile, |
| 7 | OrchestrationProposedPlanId, | |
| 7 | 8 | OrchestrationReadModel, |
| 8 | 9 | ProjectScript, |
| 10 | ThreadId, | |
| 9 | 11 | TurnId, |
| 10 | 12 | type OrchestrationCheckpointSummary, |
| 11 | 13 | type OrchestrationLatestTurn, |
| ... | ... | @@ -74,6 +76,8 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ |
| 74 | 76 | startedAt: Schema.NullOr(IsoDateTime), |
| 75 | 77 | completedAt: Schema.NullOr(IsoDateTime), |
| 76 | 78 | assistantMessageId: Schema.NullOr(MessageId), |
| 79 | sourceProposedPlanThreadId: Schema.NullOr(ThreadId), | |
| 80 | sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), | |
| 77 | 81 | }); |
| 78 | 82 | const ProjectionStateDbRowSchema = ProjectionState; |
| 79 | 83 | |
| ... | ... | @@ -286,7 +290,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 286 | 290 | requested_at AS "requestedAt", |
| 287 | 291 | started_at AS "startedAt", |
| 288 | 292 | 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" | |
| 290 | 296 | FROM projection_turns |
| 291 | 297 | WHERE turn_id IS NOT NULL |
| 292 | 298 | ORDER BY thread_id ASC, requested_at DESC, turn_id DESC |
| ... | ... | @@ -501,6 +507,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 501 | 507 | startedAt: row.startedAt, |
| 502 | 508 | completedAt: row.completedAt, |
| 503 | 509 | assistantMessageId: row.assistantMessageId, |
| 510 | ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null | |
| 511 | ? { | |
| 512 | sourceProposedPlan: { | |
| 513 | threadId: row.sourceProposedPlanThreadId, | |
| 514 | planId: row.sourceProposedPlanId, | |
| 515 | }, | |
| 516 | } | |
| 517 | : {}), | |
| 504 | 518 | }); |
| 505 | 519 | } |
| 506 | 520 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+457-6| ... | ... | @@ -83,9 +83,13 @@ describe("ProviderCommandReactor", () => { |
| 83 | 83 | createdStateDirs.clear(); |
| 84 | 84 | }); |
| 85 | 85 | |
| 86 | async function createHarness(input?: { readonly stateDir?: string }) { | |
| 86 | async function createHarness(input?: { | |
| 87 | readonly stateDir?: string; | |
| 88 | readonly threadModel?: string; | |
| 89 | }) { | |
| 87 | 90 | const now = new Date().toISOString(); |
| 88 | 91 | const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); |
| 92 | const threadModel = input?.threadModel ?? "gpt-5-codex"; | |
| 89 | 93 | createdStateDirs.add(stateDir); |
| 90 | 94 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 91 | 95 | let nextSessionIndex = 1; |
| ... | ... | @@ -96,7 +100,7 @@ describe("ProviderCommandReactor", () => { |
| 96 | 100 | typeof input === "object" && |
| 97 | 101 | input !== null && |
| 98 | 102 | "provider" in input && |
| 99 | input.provider === "codex" | |
| 103 | (input.provider === "codex" || input.provider === "claudeAgent") | |
| 100 | 104 | ? input.provider |
| 101 | 105 | : "codex"; |
| 102 | 106 | const resumeCursor = |
| ... | ... | @@ -129,7 +133,7 @@ describe("ProviderCommandReactor", () => { |
| 129 | 133 | : "full-access", |
| 130 | 134 | ...(model !== undefined ? { model } : {}), |
| 131 | 135 | threadId, |
| 132 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, | |
| 136 | resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, | |
| 133 | 137 | createdAt: now, |
| 134 | 138 | updatedAt: now, |
| 135 | 139 | }; |
| ... | ... | @@ -228,7 +232,7 @@ describe("ProviderCommandReactor", () => { |
| 228 | 232 | projectId: asProjectId("project-1"), |
| 229 | 233 | title: "Provider Project", |
| 230 | 234 | workspaceRoot: "/tmp/provider-project", |
| 231 | defaultModel: "gpt-5-codex", | |
| 235 | defaultModel: threadModel, | |
| 232 | 236 | createdAt: now, |
| 233 | 237 | }), |
| 234 | 238 | ); |
| ... | ... | @@ -239,7 +243,7 @@ describe("ProviderCommandReactor", () => { |
| 239 | 243 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 240 | 244 | projectId: asProjectId("project-1"), |
| 241 | 245 | title: "Thread", |
| 242 | model: "gpt-5-codex", | |
| 246 | model: threadModel, | |
| 243 | 247 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 244 | 248 | runtimeMode: "approval-required", |
| 245 | 249 | branch: null, |
| ... | ... | @@ -351,6 +355,106 @@ describe("ProviderCommandReactor", () => { |
| 351 | 355 | }); |
| 352 | 356 | }); |
| 353 | 357 | |
| 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 | ||
| 354 | 458 | it("forwards plan interaction mode to the provider turn request", async () => { |
| 355 | 459 | const harness = await createHarness(); |
| 356 | 460 | const now = new Date().toISOString(); |
| ... | ... | @@ -389,6 +493,102 @@ describe("ProviderCommandReactor", () => { |
| 389 | 493 | }); |
| 390 | 494 | }); |
| 391 | 495 | |
| 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 | ||
| 392 | 592 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 393 | 593 | const harness = await createHarness(); |
| 394 | 594 | const now = new Date().toISOString(); |
| ... | ... | @@ -435,6 +635,74 @@ describe("ProviderCommandReactor", () => { |
| 435 | 635 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 436 | 636 | }); |
| 437 | 637 | |
| 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 | ||
| 438 | 706 | it("restarts the provider session when runtime mode is updated on the thread", async () => { |
| 439 | 707 | const harness = await createHarness(); |
| 440 | 708 | const now = new Date().toISOString(); |
| ... | ... | @@ -509,7 +777,7 @@ describe("ProviderCommandReactor", () => { |
| 509 | 777 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 510 | 778 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 511 | 779 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 512 | resumeCursor: { opaque: "cursor-1" }, | |
| 780 | resumeCursor: { opaque: "resume-1" }, | |
| 513 | 781 | runtimeMode: "approval-required", |
| 514 | 782 | }); |
| 515 | 783 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| ... | ... | @@ -522,6 +790,77 @@ describe("ProviderCommandReactor", () => { |
| 522 | 790 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 523 | 791 | }); |
| 524 | 792 | |
| 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 | ||
| 525 | 864 | it("does not stop the active session when restart fails before rebind", async () => { |
| 526 | 865 | const harness = await createHarness(); |
| 527 | 866 | const now = new Date().toISOString(); |
| ... | ... | @@ -797,6 +1136,7 @@ describe("ProviderCommandReactor", () => { |
| 797 | 1136 | expect(failureActivity).toBeDefined(); |
| 798 | 1137 | expect(failureActivity?.payload).toMatchObject({ |
| 799 | 1138 | requestId: "approval-request-1", |
| 1139 | detail: expect.stringContaining("Stale pending approval request: approval-request-1"), | |
| 800 | 1140 | }); |
| 801 | 1141 | |
| 802 | 1142 | const resolvedActivity = thread?.activities.find( |
| ... | ... | @@ -809,6 +1149,117 @@ describe("ProviderCommandReactor", () => { |
| 809 | 1149 | expect(resolvedActivity).toBeUndefined(); |
| 810 | 1150 | }); |
| 811 | 1151 | |
| 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 | ||
| 812 | 1263 | it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { |
| 813 | 1264 | const harness = await createHarness(); |
| 814 | 1265 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+89-17| ... | ... | @@ -5,7 +5,7 @@ import { |
| 5 | 5 | EventId, |
| 6 | 6 | type OrchestrationEvent, |
| 7 | 7 | type ProviderModelOptions, |
| 8 | type ProviderKind, | |
| 8 | ProviderKind, | |
| 9 | 9 | type ProviderStartOptions, |
| 10 | 10 | type OrchestrationSession, |
| 11 | 11 | ThreadId, |
| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | ProviderCommandReactor, |
| 27 | 27 | type ProviderCommandReactorShape, |
| 28 | 28 | } from "../Services/ProviderCommandReactor.ts"; |
| 29 | import { inferProviderForModel } from "@t3tools/shared/model"; | |
| 29 | 30 | |
| 30 | 31 | type ProviderIntentEvent = Extract< |
| 31 | 32 | OrchestrationEvent, |
| ... | ... | @@ -75,6 +76,11 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 75 | 76 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 76 | 77 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); |
| 77 | 78 | |
| 79 | const sameModelOptions = ( | |
| 80 | left: ProviderModelOptions | undefined, | |
| 81 | right: ProviderModelOptions | undefined, | |
| 82 | ): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); | |
| 83 | ||
| 78 | 84 | function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean { |
| 79 | 85 | const error = Cause.squash(cause); |
| 80 | 86 | if (Schema.is(ProviderAdapterRequestError)(error)) { |
| ... | ... | @@ -91,6 +97,21 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService |
| 91 | 97 | ); |
| 92 | 98 | } |
| 93 | 99 | |
| 100 | function 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 | ||
| 108 | function 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 | ||
| 94 | 115 | function isTemporaryWorktreeBranch(branch: string): boolean { |
| 95 | 116 | return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); |
| 96 | 117 | } |
| ... | ... | @@ -137,6 +158,7 @@ const make = Effect.gen(function* () { |
| 137 | 158 | ); |
| 138 | 159 | |
| 139 | 160 | const threadProviderOptions = new Map<string, ProviderStartOptions>(); |
| 161 | const threadModelOptions = new Map<string, ProviderModelOptions>(); | |
| 140 | 162 | |
| 141 | 163 | const appendProviderFailureActivity = (input: { |
| 142 | 164 | readonly threadId: ThreadId; |
| ... | ... | @@ -206,9 +228,30 @@ const make = Effect.gen(function* () { |
| 206 | 228 | } |
| 207 | 229 | |
| 208 | 230 | 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; | |
| 212 | 255 | const desiredModel = options?.model ?? thread.model; |
| 213 | 256 | const effectiveCwd = resolveThreadWorkspaceCwd({ |
| 214 | 257 | thread, |
| ... | ... | @@ -268,8 +311,18 @@ const make = Effect.gen(function* () { |
| 268 | 311 | : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; |
| 269 | 312 | const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; |
| 270 | 313 | 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); | |
| 271 | 319 | |
| 272 | if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { | |
| 320 | if ( | |
| 321 | !runtimeModeChanged && | |
| 322 | !providerChanged && | |
| 323 | !shouldRestartForModelChange && | |
| 324 | !shouldRestartForModelOptionsChange | |
| 325 | ) { | |
| 273 | 326 | return existingSessionThreadId; |
| 274 | 327 | } |
| 275 | 328 | |
| ... | ... | @@ -288,6 +341,7 @@ const make = Effect.gen(function* () { |
| 288 | 341 | providerChanged, |
| 289 | 342 | modelChanged, |
| 290 | 343 | shouldRestartForModelChange, |
| 344 | shouldRestartForModelOptionsChange, | |
| 291 | 345 | hasResumeCursor: resumeCursor !== undefined, |
| 292 | 346 | }); |
| 293 | 347 | const restartedSession = yield* startProviderSession({ |
| ... | ... | @@ -327,15 +381,18 @@ const make = Effect.gen(function* () { |
| 327 | 381 | if (!thread) { |
| 328 | 382 | return; |
| 329 | 383 | } |
| 330 | if (input.providerOptions !== undefined) { | |
| 331 | threadProviderOptions.set(input.threadId, input.providerOptions); | |
| 332 | } | |
| 333 | 384 | yield* ensureSessionForThread(input.threadId, input.createdAt, { |
| 334 | 385 | ...(input.provider !== undefined ? { provider: input.provider } : {}), |
| 335 | 386 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 336 | 387 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), |
| 337 | 388 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), |
| 338 | 389 | }); |
| 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 | } | |
| 339 | 396 | const normalizedInput = toNonEmptyProviderInput(input.messageText); |
| 340 | 397 | const normalizedAttachments = input.attachments ?? []; |
| 341 | 398 | const activeSession = yield* providerService |
| ... | ... | @@ -477,7 +534,18 @@ const make = Effect.gen(function* () { |
| 477 | 534 | : {}), |
| 478 | 535 | interactionMode: event.payload.interactionMode, |
| 479 | 536 | 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 | ); | |
| 481 | 549 | }); |
| 482 | 550 | |
| 483 | 551 | const processTurnInterruptRequested = Effect.fnUntraced(function* ( |
| ... | ... | @@ -536,7 +604,9 @@ const make = Effect.gen(function* () { |
| 536 | 604 | threadId: event.payload.threadId, |
| 537 | 605 | kind: "provider.approval.respond.failed", |
| 538 | 606 | summary: "Provider approval response failed", |
| 539 | detail: Cause.pretty(cause), | |
| 607 | detail: isUnknownPendingApprovalRequestError(cause) | |
| 608 | ? stalePendingRequestDetail("approval", event.payload.requestId) | |
| 609 | : Cause.pretty(cause), | |
| 540 | 610 | turnId: null, |
| 541 | 611 | createdAt: event.payload.createdAt, |
| 542 | 612 | requestId: event.payload.requestId, |
| ... | ... | @@ -580,7 +650,9 @@ const make = Effect.gen(function* () { |
| 580 | 650 | threadId: event.payload.threadId, |
| 581 | 651 | kind: "provider.user-input.respond.failed", |
| 582 | 652 | 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), | |
| 584 | 656 | turnId: null, |
| 585 | 657 | createdAt: event.payload.createdAt, |
| 586 | 658 | requestId: event.payload.requestId, |
| ... | ... | @@ -626,13 +698,13 @@ const make = Effect.gen(function* () { |
| 626 | 698 | return; |
| 627 | 699 | } |
| 628 | 700 | 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 | |
| 633 | 704 | ? { providerOptions: cachedProviderOptions } |
| 634 | : undefined, | |
| 635 | ); | |
| 705 | : {}), | |
| 706 | ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), | |
| 707 | }); | |
| 636 | 708 | return; |
| 637 | 709 | } |
| 638 | 710 | case "thread.turn-start-requested": |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+60-3| ... | ... | @@ -4,6 +4,7 @@ import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import type { |
| 6 | 6 | OrchestrationReadModel, |
| 7 | ProviderKind, | |
| 7 | 8 | ProviderRuntimeEvent, |
| 8 | 9 | ProviderSession, |
| 9 | 10 | } from "@t3tools/contracts"; |
| ... | ... | @@ -49,7 +50,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 49 | 50 | type LegacyProviderRuntimeEvent = { |
| 50 | 51 | readonly type: string; |
| 51 | 52 | readonly eventId: EventId; |
| 52 | readonly provider: "codex"; | |
| 53 | readonly provider: ProviderKind; | |
| 53 | 54 | readonly createdAt: string; |
| 54 | 55 | readonly threadId: ThreadId; |
| 55 | 56 | readonly turnId?: string | undefined; |
| ... | ... | @@ -428,6 +429,60 @@ describe("ProviderRuntimeIngestion", () => { |
| 428 | 429 | ); |
| 429 | 430 | }); |
| 430 | 431 | |
| 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 | ||
| 431 | 486 | it("ignores auxiliary turn completions from a different provider thread", async () => { |
| 432 | 487 | const harness = await createHarness(); |
| 433 | 488 | const now = new Date().toISOString(); |
| ... | ... | @@ -1870,6 +1925,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 1870 | 1925 | payload: { |
| 1871 | 1926 | taskId: "turn-task-1", |
| 1872 | 1927 | description: "Comparing the desktop rollout chunks to the app-server stream.", |
| 1928 | summary: "Code reviewer is validating the desktop rollout chunks.", | |
| 1873 | 1929 | }, |
| 1874 | 1930 | }); |
| 1875 | 1931 | |
| ... | ... | @@ -1932,8 +1988,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1932 | 1988 | expect(started?.kind).toBe("task.started"); |
| 1933 | 1989 | expect(started?.summary).toBe("Plan task started"); |
| 1934 | 1990 | 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.", | |
| 1937 | 1994 | ); |
| 1938 | 1995 | expect(completed?.kind).toBe("task.completed"); |
| 1939 | 1996 | 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( |
| 373 | 373 | summary: "Reasoning update", |
| 374 | 374 | payload: { |
| 375 | 375 | 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) } : {}), | |
| 377 | 378 | ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), |
| 378 | 379 | ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), |
| 379 | 380 | }, |
apps/server/src/persistence/Layers/ProjectionTurns.ts+10| ... | ... | @@ -47,6 +47,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { |
| 47 | 47 | thread_id, |
| 48 | 48 | turn_id, |
| 49 | 49 | pending_message_id, |
| 50 | source_proposed_plan_thread_id, | |
| 51 | source_proposed_plan_id, | |
| 50 | 52 | assistant_message_id, |
| 51 | 53 | state, |
| 52 | 54 | requested_at, |
| ... | ... | @@ -61,6 +63,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { |
| 61 | 63 | ${row.threadId}, |
| 62 | 64 | ${row.turnId}, |
| 63 | 65 | ${row.pendingMessageId}, |
| 66 | ${row.sourceProposedPlanThreadId}, | |
| 67 | ${row.sourceProposedPlanId}, | |
| 64 | 68 | ${row.assistantMessageId}, |
| 65 | 69 | ${row.state}, |
| 66 | 70 | ${row.requestedAt}, |
| ... | ... | @@ -74,6 +78,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { |
| 74 | 78 | ON CONFLICT (thread_id, turn_id) |
| 75 | 79 | DO UPDATE SET |
| 76 | 80 | 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, | |
| 77 | 83 | assistant_message_id = excluded.assistant_message_id, |
| 78 | 84 | state = excluded.state, |
| 79 | 85 | requested_at = excluded.requested_at, |
| ... | ... | @@ -168,6 +174,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { |
| 168 | 174 | thread_id AS "threadId", |
| 169 | 175 | turn_id AS "turnId", |
| 170 | 176 | pending_message_id AS "pendingMessageId", |
| 177 | source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", | |
| 178 | source_proposed_plan_id AS "sourceProposedPlanId", | |
| 171 | 179 | assistant_message_id AS "assistantMessageId", |
| 172 | 180 | state, |
| 173 | 181 | requested_at AS "requestedAt", |
| ... | ... | @@ -199,6 +207,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { |
| 199 | 207 | thread_id AS "threadId", |
| 200 | 208 | turn_id AS "turnId", |
| 201 | 209 | pending_message_id AS "pendingMessageId", |
| 210 | source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", | |
| 211 | source_proposed_plan_id AS "sourceProposedPlanId", | |
| 202 | 212 | assistant_message_id AS "assistantMessageId", |
| 203 | 213 | state, |
| 204 | 214 | requested_at AS "requestedAt", |
apps/server/src/persistence/Services/ProjectionTurns.ts+4| ... | ... | @@ -35,6 +35,8 @@ export const ProjectionTurn = Schema.Struct({ |
| 35 | 35 | threadId: ThreadId, |
| 36 | 36 | turnId: Schema.NullOr(TurnId), |
| 37 | 37 | pendingMessageId: Schema.NullOr(MessageId), |
| 38 | sourceProposedPlanThreadId: Schema.NullOr(ThreadId), | |
| 39 | sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), | |
| 38 | 40 | assistantMessageId: Schema.NullOr(MessageId), |
| 39 | 41 | state: ProjectionTurnState, |
| 40 | 42 | requestedAt: IsoDateTime, |
| ... | ... | @@ -51,6 +53,8 @@ export const ProjectionTurnById = Schema.Struct({ |
| 51 | 53 | threadId: ThreadId, |
| 52 | 54 | turnId: TurnId, |
| 53 | 55 | pendingMessageId: Schema.NullOr(MessageId), |
| 56 | sourceProposedPlanThreadId: Schema.NullOr(ThreadId), | |
| 57 | sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), | |
| 54 | 58 | assistantMessageId: Schema.NullOr(MessageId), |
| 55 | 59 | state: ProjectionTurnState, |
| 56 | 60 | requestedAt: IsoDateTime, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts created+2763| ... | ... | @@ -0,0 +1,2763 @@ |
| 1 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | import type { | |
| 7 | Options as ClaudeQueryOptions, | |
| 8 | PermissionMode, | |
| 9 | PermissionResult, | |
| 10 | SDKMessage, | |
| 11 | SDKUserMessage, | |
| 12 | } from "@anthropic-ai/claude-agent-sdk"; | |
| 13 | import { | |
| 14 | ApprovalRequestId, | |
| 15 | ProviderItemId, | |
| 16 | ProviderRuntimeEvent, | |
| 17 | ThreadId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { assert, describe, it } from "@effect/vitest"; | |
| 20 | import { Effect, Fiber, Layer, Random, Stream } from "effect"; | |
| 21 | ||
| 22 | import { attachmentRelativePath } from "../../attachmentStore.ts"; | |
| 23 | import { ServerConfig } from "../../config.ts"; | |
| 24 | import { ProviderAdapterValidationError } from "../Errors.ts"; | |
| 25 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | |
| 26 | import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; | |
| 27 | ||
| 28 | class 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 | ||
| 132 | function 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 | ||
| 178 | function 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 | ||
| 194 | async 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 | ||
| 216 | async 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 | ||
| 234 | const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); | |
| 235 | const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); | |
| 236 | ||
| 237 | describe("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 | */ | |
| 9 | import { | |
| 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"; | |
| 20 | import { | |
| 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"; | |
| 40 | import { | |
| 41 | applyClaudePromptEffortPrefix, | |
| 42 | getEffectiveClaudeCodeEffort, | |
| 43 | getReasoningEffortOptions, | |
| 44 | resolveReasoningEffortForProvider, | |
| 45 | supportsClaudeFastMode, | |
| 46 | supportsClaudeThinkingToggle, | |
| 47 | supportsClaudeUltrathinkKeyword, | |
| 48 | } from "@t3tools/shared/model"; | |
| 49 | import { | |
| 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 | ||
| 64 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 65 | import { ServerConfig } from "../../config.ts"; | |
| 66 | import { | |
| 67 | ProviderAdapterProcessError, | |
| 68 | ProviderAdapterRequestError, | |
| 69 | ProviderAdapterSessionClosedError, | |
| 70 | ProviderAdapterSessionNotFoundError, | |
| 71 | ProviderAdapterValidationError, | |
| 72 | type ProviderAdapterError, | |
| 73 | } from "../Errors.ts"; | |
| 74 | import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; | |
| 75 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 76 | ||
| 77 | const PROVIDER = "claudeAgent" as const; | |
| 78 | type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">; | |
| 79 | type ClaudeToolResultStreamKind = Extract< | |
| 80 | RuntimeContentStreamKind, | |
| 81 | "command_output" | "file_change_output" | |
| 82 | >; | |
| 83 | ||
| 84 | type PromptQueueItem = | |
| 85 | | { | |
| 86 | readonly type: "message"; | |
| 87 | readonly message: SDKUserMessage; | |
| 88 | } | |
| 89 | | { | |
| 90 | readonly type: "terminate"; | |
| 91 | }; | |
| 92 | ||
| 93 | interface ClaudeResumeState { | |
| 94 | readonly threadId?: ThreadId; | |
| 95 | readonly resume?: string; | |
| 96 | readonly resumeSessionAt?: string; | |
| 97 | readonly turnCount?: number; | |
| 98 | } | |
| 99 | ||
| 100 | interface 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 | ||
| 110 | interface AssistantTextBlockState { | |
| 111 | readonly itemId: string; | |
| 112 | readonly blockIndex: number; | |
| 113 | emittedTextDelta: boolean; | |
| 114 | fallbackText: string; | |
| 115 | streamClosed: boolean; | |
| 116 | completionEmitted: boolean; | |
| 117 | } | |
| 118 | ||
| 119 | interface PendingApproval { | |
| 120 | readonly requestType: CanonicalRequestType; | |
| 121 | readonly detail?: string; | |
| 122 | readonly suggestions?: ReadonlyArray<PermissionUpdate>; | |
| 123 | readonly decision: Deferred.Deferred<ProviderApprovalDecision>; | |
| 124 | } | |
| 125 | ||
| 126 | interface PendingUserInput { | |
| 127 | readonly questions: ReadonlyArray<UserInputQuestion>; | |
| 128 | readonly answers: Deferred.Deferred<ProviderUserInputAnswers>; | |
| 129 | } | |
| 130 | ||
| 131 | interface 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 | ||
| 142 | interface 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 | ||
| 163 | interface 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 | ||
| 171 | export 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 | ||
| 180 | function 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 | ||
| 184 | function isSyntheticClaudeThreadId(value: string): boolean { | |
| 185 | return value.startsWith("claude-thread-"); | |
| 186 | } | |
| 187 | ||
| 188 | function 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 | ||
| 195 | function toError(cause: unknown, fallback: string): Error { | |
| 196 | return cause instanceof Error ? cause : new Error(toMessage(cause, fallback)); | |
| 197 | } | |
| 198 | ||
| 199 | function 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 | ||
| 211 | function 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 | ||
| 220 | function isClaudeInterruptedCause(cause: Cause.Cause<Error>): boolean { | |
| 221 | return ( | |
| 222 | Cause.hasInterruptsOnly(cause) || | |
| 223 | normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | function messageFromClaudeStreamCause(cause: Cause.Cause<Error>, fallback: string): string { | |
| 228 | return normalizeClaudeStreamMessages(cause)[0] ?? fallback; | |
| 229 | } | |
| 230 | ||
| 231 | function interruptionMessageFromClaudeCause(cause: Cause.Cause<Error>): string { | |
| 232 | const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted."); | |
| 233 | return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message; | |
| 234 | } | |
| 235 | ||
| 236 | function resultErrorsText(result: SDKResultMessage): string { | |
| 237 | return "errors" in result && Array.isArray(result.errors) | |
| 238 | ? result.errors.join(" ").toLowerCase() | |
| 239 | : ""; | |
| 240 | } | |
| 241 | ||
| 242 | function 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 | ||
| 257 | function asRuntimeItemId(value: string): RuntimeItemId { | |
| 258 | return RuntimeItemId.makeUnsafe(value); | |
| 259 | } | |
| 260 | ||
| 261 | function asCanonicalTurnId(value: TurnId): TurnId { | |
| 262 | return value; | |
| 263 | } | |
| 264 | ||
| 265 | function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { | |
| 266 | return RuntimeRequestId.makeUnsafe(value); | |
| 267 | } | |
| 268 | ||
| 269 | function 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 | ||
| 282 | function 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 | ||
| 320 | function 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 | ||
| 364 | function 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 | ||
| 376 | function 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 | ||
| 388 | function 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 | ||
| 402 | function 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 | ||
| 423 | const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ | |
| 424 | "image/gif", | |
| 425 | "image/jpeg", | |
| 426 | "image/png", | |
| 427 | "image/webp", | |
| 428 | ]); | |
| 429 | ||
| 430 | function 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 | ||
| 445 | function 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 | ||
| 459 | function 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 | ||
| 473 | function 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 | ||
| 537 | function 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 | ||
| 552 | function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind { | |
| 553 | return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; | |
| 554 | } | |
| 555 | ||
| 556 | function 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 | ||
| 570 | function 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 | ||
| 598 | function 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 | ||
| 607 | function 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 | ||
| 632 | function 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 | ||
| 645 | function 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 | ||
| 654 | function 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 | ||
| 665 | function toolInputFingerprint(input: Record<string, unknown>): string | undefined { | |
| 666 | try { | |
| 667 | return JSON.stringify(input); | |
| 668 | } catch { | |
| 669 | return undefined; | |
| 670 | } | |
| 671 | } | |
| 672 | ||
| 673 | function 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 | ||
| 684 | function 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 | ||
| 732 | function 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 | ||
| 754 | function 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 | ||
| 767 | function 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 | ||
| 775 | function 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 | ||
| 783 | function 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 | ||
| 806 | function 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 | ||
| 832 | function 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 | ||
| 2908 | export const ClaudeAdapterLive = Layer.effect(ClaudeAdapter, makeClaudeAdapter()); | |
| 2909 | ||
| 2910 | export 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 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import { PassThrough } from "node:stream"; | |
| 3 | import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; | |
| 4 | import { afterEach, describe, expect, it } from "vitest"; | |
| 5 | ||
| 6 | async function* emptyPrompt(): AsyncGenerator<never, void, void> {} | |
| 7 | ||
| 8 | class 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 | ||
| 85 | describe("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 { |
| 22 | 22 | type CodexAppServerSendTurnInput, |
| 23 | 23 | } from "../../codexAppServerManager.ts"; |
| 24 | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; | |
| 25 | 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 26 | 27 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 27 | 28 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| ... | ... | @@ -156,6 +157,29 @@ const validationLayer = it.layer( |
| 156 | 157 | ); |
| 157 | 158 | |
| 158 | 159 | validationLayer("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 | ); | |
| 159 | 183 | it.effect("maps codex model options before starting a session", () => |
| 160 | 184 | Effect.gen(function* () { |
| 161 | 185 | validationManager.startSessionImpl.mockClear(); |
| ... | ... | @@ -776,6 +800,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 776 | 800 | } |
| 777 | 801 | }), |
| 778 | 802 | ); |
| 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 | ); | |
| 779 | 842 | }); |
| 780 | 843 | |
| 781 | 844 | afterAll(() => { |
apps/server/src/provider/Layers/CodexAdapter.ts+14-6| ... | ... | @@ -109,6 +109,14 @@ function asNumber(value: unknown): number | undefined { |
| 109 | 109 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | function toTurnId(value: string | undefined): TurnId | undefined { | |
| 113 | return value?.trim() ? TurnId.makeUnsafe(value) : undefined; | |
| 114 | } | |
| 115 | ||
| 116 | function toProviderItemId(value: string | undefined): ProviderItemId | undefined { | |
| 117 | return value?.trim() ? ProviderItemId.makeUnsafe(value) : undefined; | |
| 118 | } | |
| 119 | ||
| 112 | 120 | function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { |
| 113 | 121 | switch (value) { |
| 114 | 122 | case "completed": |
| ... | ... | @@ -415,27 +423,27 @@ function codexEventBase( |
| 415 | 423 | ): Omit<ProviderRuntimeEvent, "type" | "payload"> { |
| 416 | 424 | const payload = asObject(event.payload); |
| 417 | 425 | 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)); | |
| 420 | 428 | const requestId = asString(msg?.request_id) ?? asString(msg?.requestId); |
| 421 | 429 | const base = runtimeEventBase(event, canonicalThreadId); |
| 422 | 430 | const providerRefs = base.providerRefs |
| 423 | 431 | ? { |
| 424 | 432 | ...base.providerRefs, |
| 425 | 433 | ...(turnId ? { providerTurnId: turnId } : {}), |
| 426 | ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 434 | ...(itemId ? { providerItemId: itemId } : {}), | |
| 427 | 435 | ...(requestId ? { providerRequestId: requestId } : {}), |
| 428 | 436 | } |
| 429 | 437 | : { |
| 430 | 438 | ...(turnId ? { providerTurnId: turnId } : {}), |
| 431 | ...(itemId ? { providerItemId: ProviderItemId.makeUnsafe(itemId) } : {}), | |
| 439 | ...(itemId ? { providerItemId: itemId } : {}), | |
| 432 | 440 | ...(requestId ? { providerRequestId: requestId } : {}), |
| 433 | 441 | }; |
| 434 | 442 | |
| 435 | 443 | return { |
| 436 | 444 | ...base, |
| 437 | ...(turnId ? { turnId: TurnId.makeUnsafe(turnId) } : {}), | |
| 438 | ...(itemId ? { itemId: asRuntimeItemId(ProviderItemId.makeUnsafe(itemId)) } : {}), | |
| 445 | ...(turnId ? { turnId } : {}), | |
| 446 | ...(itemId ? { itemId: asRuntimeItemId(itemId) } : {}), | |
| 439 | 447 | ...(requestId ? { requestId: asRuntimeRequestId(requestId) } : {}), |
| 440 | 448 | ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}), |
| 441 | 449 | }; |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+28-2| ... | ... | @@ -4,6 +4,7 @@ import { assertFailure } from "@effect/vitest/utils"; |
| 4 | 4 | |
| 5 | 5 | import { Effect, Layer, Stream } from "effect"; |
| 6 | 6 | |
| 7 | import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; | |
| 7 | 8 | import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 8 | 9 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 9 | 10 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -27,9 +28,32 @@ const fakeCodexAdapter: CodexAdapterShape = { |
| 27 | 28 | streamEvents: Stream.empty, |
| 28 | 29 | }; |
| 29 | 30 | |
| 31 | const 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 | ||
| 30 | 48 | const layer = it.layer( |
| 31 | 49 | 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 | ), | |
| 33 | 57 | NodeServices.layer, |
| 34 | 58 | ), |
| 35 | 59 | ); |
| ... | ... | @@ -39,10 +63,12 @@ layer("ProviderAdapterRegistryLive", (it) => { |
| 39 | 63 | Effect.gen(function* () { |
| 40 | 64 | const registry = yield* ProviderAdapterRegistry; |
| 41 | 65 | const codex = yield* registry.getByProvider("codex"); |
| 66 | const claude = yield* registry.getByProvider("claudeAgent"); | |
| 42 | 67 | assert.equal(codex, fakeCodexAdapter); |
| 68 | assert.equal(claude, fakeClaudeAdapter); | |
| 43 | 69 | |
| 44 | 70 | const providers = yield* registry.listProviders(); |
| 45 | assert.deepEqual(providers, ["codex"]); | |
| 71 | assert.deepEqual(providers, ["codex", "claudeAgent"]); | |
| 46 | 72 | }), |
| 47 | 73 | ); |
| 48 | 74 |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+6-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | /** |
| 2 | 2 | * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. |
| 3 | 3 | * |
| 4 | * Binds provider kinds (codex/cursor/...) to concrete adapter services. | |
| 4 | * Binds provider kinds (codex/claudeAgent/...) to concrete adapter services. | |
| 5 | 5 | * This layer only performs adapter lookup; it does not route session-scoped |
| 6 | 6 | * calls or own provider lifecycle workflows. |
| 7 | 7 | * |
| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | ProviderAdapterRegistry, |
| 16 | 16 | type ProviderAdapterRegistryShape, |
| 17 | 17 | } from "../Services/ProviderAdapterRegistry.ts"; |
| 18 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | |
| 18 | 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 19 | 20 | |
| 20 | 21 | export interface ProviderAdapterRegistryLiveOptions { |
| ... | ... | @@ -23,7 +24,10 @@ export interface ProviderAdapterRegistryLiveOptions { |
| 23 | 24 | |
| 24 | 25 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => |
| 25 | 26 | 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]; | |
| 27 | 31 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 28 | 32 | |
| 29 | 33 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
apps/server/src/provider/Layers/ProviderHealth.test.ts+173| ... | ... | @@ -5,9 +5,11 @@ import * as PlatformError from "effect/PlatformError"; |
| 5 | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | checkClaudeProviderStatus, | |
| 8 | 9 | checkCodexProviderStatus, |
| 9 | 10 | hasCustomModelProvider, |
| 10 | 11 | parseAuthStatusFromOutput, |
| 12 | parseClaudeAuthStatusFromOutput, | |
| 11 | 13 | readCodexConfigModelProvider, |
| 12 | 14 | } from "./ProviderHealth"; |
| 13 | 15 | |
| ... | ... | @@ -464,4 +466,175 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { |
| 464 | 466 | }), |
| 465 | 467 | ); |
| 466 | 468 | }); |
| 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 | }); | |
| 467 | 640 | }); |
apps/server/src/provider/Layers/ProviderHealth.ts+207-11| ... | ... | @@ -26,6 +26,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe |
| 26 | 26 | |
| 27 | 27 | const DEFAULT_TIMEOUT_MS = 4_000; |
| 28 | 28 | const CODEX_PROVIDER = "codex" as const; |
| 29 | const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; | |
| 29 | 30 | |
| 30 | 31 | // ── Pure helpers ──────────────────────────────────────────────────── |
| 31 | 32 | |
| ... | ... | @@ -44,12 +45,7 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { |
| 44 | 45 | function isCommandMissingCause(error: unknown): boolean { |
| 45 | 46 | if (!(error instanceof Error)) return false; |
| 46 | 47 | 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"); | |
| 53 | 49 | } |
| 54 | 50 | |
| 55 | 51 | function detailFromResult( |
| ... | ... | @@ -264,6 +260,27 @@ const runCodexCommand = (args: ReadonlyArray<string>) => |
| 264 | 260 | return { stdout, stderr, code: exitCode } satisfies CommandResult; |
| 265 | 261 | }).pipe(Effect.scoped); |
| 266 | 262 | |
| 263 | const 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 | ||
| 267 | 284 | // ── Health check ──────────────────────────────────────────────────── |
| 268 | 285 | |
| 269 | 286 | export const checkCodexProviderStatus: Effect.Effect< |
| ... | ... | @@ -390,18 +407,197 @@ export const checkCodexProviderStatus: Effect.Effect< |
| 390 | 407 | } satisfies ServerProviderStatus; |
| 391 | 408 | }); |
| 392 | 409 | |
| 410 | // ── Claude Agent health check ─────────────────────────────────────── | |
| 411 | ||
| 412 | export 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 | ||
| 494 | export 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 | ||
| 393 | 590 | // ── Layer ─────────────────────────────────────────────────────────── |
| 394 | 591 | |
| 395 | 592 | export const ProviderHealthLive = Layer.effect( |
| 396 | 593 | ProviderHealth, |
| 397 | 594 | 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); | |
| 402 | 598 | |
| 403 | 599 | return { |
| 404 | getStatuses: Fiber.join(codexStatusFiber), | |
| 600 | getStatuses: Fiber.join(statusesFiber), | |
| 405 | 601 | } satisfies ProviderHealthShape; |
| 406 | 602 | }), |
| 407 | 603 | ); |
apps/server/src/provider/Layers/ProviderService.test.ts+205-11| ... | ... | @@ -52,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 52 | 52 | type LegacyProviderRuntimeEvent = { |
| 53 | 53 | readonly type: string; |
| 54 | 54 | readonly eventId: EventId; |
| 55 | readonly provider: "codex"; | |
| 55 | readonly provider: ProviderKind; | |
| 56 | 56 | readonly createdAt: string; |
| 57 | 57 | readonly threadId: ThreadId; |
| 58 | 58 | readonly turnId?: string | undefined; |
| ... | ... | @@ -74,7 +74,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 74 | 74 | status: "ready", |
| 75 | 75 | runtimeMode: input.runtimeMode, |
| 76 | 76 | threadId: input.threadId, |
| 77 | resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` }, | |
| 77 | resumeCursor: input.resumeCursor ?? { opaque: `resume-${String(input.threadId)}` }, | |
| 78 | 78 | cwd: input.cwd ?? process.cwd(), |
| 79 | 79 | createdAt: now, |
| 80 | 80 | updatedAt: now, |
| ... | ... | @@ -195,9 +195,21 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 195 | 195 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); |
| 196 | 196 | }; |
| 197 | 197 | |
| 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 | ||
| 198 | 209 | return { |
| 199 | 210 | adapter, |
| 200 | 211 | emit, |
| 212 | updateSession, | |
| 201 | 213 | startSession, |
| 202 | 214 | sendTurn, |
| 203 | 215 | interruptTurn, |
| ... | ... | @@ -217,12 +229,15 @@ const sleep = (ms: number) => |
| 217 | 229 | |
| 218 | 230 | function makeProviderServiceLayer() { |
| 219 | 231 | const codex = makeFakeCodexAdapter(); |
| 232 | const claude = makeFakeCodexAdapter("claudeAgent"); | |
| 220 | 233 | const registry: typeof ProviderAdapterRegistry.Service = { |
| 221 | 234 | getByProvider: (provider) => |
| 222 | 235 | provider === "codex" |
| 223 | 236 | ? 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"]), | |
| 226 | 241 | }; |
| 227 | 242 | |
| 228 | 243 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| ... | ... | @@ -247,6 +262,7 @@ function makeProviderServiceLayer() { |
| 247 | 262 | |
| 248 | 263 | return { |
| 249 | 264 | codex, |
| 265 | claude, | |
| 250 | 266 | layer, |
| 251 | 267 | }; |
| 252 | 268 | } |
| ... | ... | @@ -344,16 +360,29 @@ it.effect( |
| 344 | 360 | Layer.provide(firstDirectoryLayer), |
| 345 | 361 | Layer.provide(AnalyticsService.layerTest), |
| 346 | 362 | ); |
| 363 | const updatedResumeCursor = { | |
| 364 | threadId: asThreadId("thread-1"), | |
| 365 | resume: "resume-session-1", | |
| 366 | resumeSessionAt: "assistant-message-1", | |
| 367 | turnCount: 1, | |
| 368 | }; | |
| 347 | 369 | |
| 348 | 370 | const startedSession = yield* Effect.gen(function* () { |
| 349 | 371 | const provider = yield* ProviderService; |
| 350 | 372 | const threadId = asThreadId("thread-1"); |
| 351 | return yield* provider.startSession(threadId, { | |
| 373 | const session = yield* provider.startSession(threadId, { | |
| 352 | 374 | provider: "codex", |
| 353 | 375 | cwd: "/tmp/project", |
| 354 | 376 | runtimeMode: "full-access", |
| 355 | 377 | threadId, |
| 356 | 378 | }); |
| 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; | |
| 357 | 386 | }).pipe(Effect.provide(firstProviderLayer)); |
| 358 | 387 | |
| 359 | 388 | const persistedAfterStopAll = yield* Effect.gen(function* () { |
| ... | ... | @@ -363,7 +392,7 @@ it.effect( |
| 363 | 392 | assert.equal(Option.isSome(persistedAfterStopAll), true); |
| 364 | 393 | if (Option.isSome(persistedAfterStopAll)) { |
| 365 | 394 | assert.equal(persistedAfterStopAll.value.status, "stopped"); |
| 366 | assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); | |
| 395 | assert.deepEqual(persistedAfterStopAll.value.resumeCursor, updatedResumeCursor); | |
| 367 | 396 | } |
| 368 | 397 | |
| 369 | 398 | const secondCodex = makeFakeCodexAdapter(); |
| ... | ... | @@ -406,7 +435,7 @@ it.effect( |
| 406 | 435 | }; |
| 407 | 436 | assert.equal(startPayload.provider, "codex"); |
| 408 | 437 | assert.equal(startPayload.cwd, "/tmp/project"); |
| 409 | assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor); | |
| 438 | assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor); | |
| 410 | 439 | assert.equal(startPayload.threadId, startedSession.threadId); |
| 411 | 440 | } |
| 412 | 441 | assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); |
| ... | ... | @@ -533,6 +562,29 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 533 | 562 | }), |
| 534 | 563 | ); |
| 535 | 564 | |
| 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 | ||
| 536 | 588 | it.effect("recovers stale sessions for sendTurn using persisted cwd", () => |
| 537 | 589 | Effect.gen(function* () { |
| 538 | 590 | const provider = yield* ProviderService; |
| ... | ... | @@ -573,6 +625,57 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 573 | 625 | }), |
| 574 | 626 | ); |
| 575 | 627 | |
| 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 | ||
| 576 | 679 | it.effect("lists no sessions after adapter runtime clears", () => |
| 577 | 680 | Effect.gen(function* () { |
| 578 | 681 | const provider = yield* ProviderService; |
| ... | ... | @@ -589,6 +692,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 589 | 692 | }); |
| 590 | 693 | |
| 591 | 694 | yield* routing.codex.stopAll(); |
| 695 | yield* routing.claude.stopAll(); | |
| 592 | 696 | |
| 593 | 697 | const remaining = yield* provider.listSessions(); |
| 594 | 698 | assert.equal(remaining.length, 0); |
| ... | ... | @@ -637,6 +741,96 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 637 | 741 | } |
| 638 | 742 | }), |
| 639 | 743 | ); |
| 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 | ); | |
| 640 | 834 | }); |
| 641 | 835 | |
| 642 | 836 | const fanout = makeProviderServiceLayer(); |
| ... | ... | @@ -654,7 +848,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 654 | 848 | const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => |
| 655 | 849 | Ref.update(eventsRef, (current) => [...current, event]), |
| 656 | 850 | ).pipe(Effect.forkChild); |
| 657 | yield* sleep(20); | |
| 851 | yield* sleep(50); | |
| 658 | 852 | |
| 659 | 853 | const completedEvent: LegacyProviderRuntimeEvent = { |
| 660 | 854 | type: "turn.completed", |
| ... | ... | @@ -667,7 +861,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 667 | 861 | }; |
| 668 | 862 | |
| 669 | 863 | fanout.codex.emit(completedEvent); |
| 670 | yield* sleep(20); | |
| 864 | yield* sleep(50); | |
| 671 | 865 | |
| 672 | 866 | const events = yield* Ref.get(eventsRef); |
| 673 | 867 | yield* Fiber.interrupt(consumer); |
| ... | ... | @@ -693,7 +887,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 693 | 887 | Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])), |
| 694 | 888 | Effect.forkChild, |
| 695 | 889 | ); |
| 696 | yield* sleep(20); | |
| 890 | yield* sleep(50); | |
| 697 | 891 | |
| 698 | 892 | fanout.codex.emit({ |
| 699 | 893 | type: "tool.started", |
| ... | ... | @@ -757,7 +951,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 757 | 951 | Stream.runForEach(() => Effect.fail("listener crash")), |
| 758 | 952 | Effect.forkChild, |
| 759 | 953 | ); |
| 760 | yield* sleep(20); | |
| 954 | yield* sleep(50); | |
| 761 | 955 | |
| 762 | 956 | const events: ReadonlyArray<LegacyProviderRuntimeEvent> = [ |
| 763 | 957 | { |
apps/server/src/provider/Layers/ProviderService.ts+52-4| ... | ... | @@ -88,17 +88,38 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st |
| 88 | 88 | |
| 89 | 89 | function toRuntimePayloadFromSession( |
| 90 | 90 | 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 | }, | |
| 92 | 97 | ): Record<string, unknown> { |
| 93 | 98 | return { |
| 94 | 99 | cwd: session.cwd ?? null, |
| 95 | 100 | model: session.model ?? null, |
| 96 | 101 | activeTurnId: session.activeTurnId ?? null, |
| 97 | 102 | lastError: session.lastError ?? null, |
| 103 | ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), | |
| 98 | 104 | ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), |
| 105 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), | |
| 106 | ...(extra?.lastRuntimeEventAt !== undefined | |
| 107 | ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } | |
| 108 | : {}), | |
| 99 | 109 | }; |
| 100 | 110 | } |
| 101 | 111 | |
| 112 | function 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 | ||
| 102 | 123 | function readPersistedProviderOptions( |
| 103 | 124 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], |
| 104 | 125 | ): Record<string, unknown> | undefined { |
| ... | ... | @@ -150,7 +171,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 150 | 171 | const upsertSessionBinding = ( |
| 151 | 172 | session: ProviderSession, |
| 152 | 173 | 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 | }, | |
| 154 | 180 | ) => |
| 155 | 181 | directory.upsert({ |
| 156 | 182 | threadId, |
| ... | ... | @@ -165,7 +191,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 165 | 191 | const adapters = yield* Effect.forEach(providers, (provider) => |
| 166 | 192 | registry.getByProvider(provider), |
| 167 | 193 | ); |
| 168 | ||
| 169 | 194 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 170 | 195 | publishRuntimeEvent(event); |
| 171 | 196 | |
| ... | ... | @@ -213,12 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 213 | 238 | } |
| 214 | 239 | |
| 215 | 240 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 241 | const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); | |
| 216 | 242 | const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); |
| 217 | 243 | |
| 218 | 244 | const resumed = yield* adapter.startSession({ |
| 219 | 245 | threadId: input.binding.threadId, |
| 220 | 246 | provider: input.binding.provider, |
| 221 | 247 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 248 | ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), | |
| 222 | 249 | ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), |
| 223 | 250 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 224 | 251 | runtimeMode: input.binding.runtimeMode ?? "full-access", |
| ... | ... | @@ -281,8 +308,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 281 | 308 | threadId, |
| 282 | 309 | provider: parsed.provider ?? "codex", |
| 283 | 310 | }; |
| 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); | |
| 284 | 317 | 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 | }); | |
| 286 | 322 | |
| 287 | 323 | if (session.provider !== adapter.provider) { |
| 288 | 324 | return yield* toValidationError( |
| ... | ... | @@ -292,6 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 292 | 328 | } |
| 293 | 329 | |
| 294 | 330 | yield* upsertSessionBinding(session, threadId, { |
| 331 | modelOptions: input.modelOptions, | |
| 295 | 332 | providerOptions: input.providerOptions, |
| 296 | 333 | }); |
| 297 | 334 | yield* analytics.record("provider.session.started", { |
| ... | ... | @@ -498,6 +535,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 498 | 535 | const runStopAll = () => |
| 499 | 536 | Effect.gen(function* () { |
| 500 | 537 | 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); | |
| 501 | 549 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); |
| 502 | 550 | yield* Effect.forEach(threadIds, (threadId) => |
| 503 | 551 | directory.getProvider(threadId).pipe( |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-2| ... | ... | @@ -141,8 +141,8 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 141 | 141 | |
| 142 | 142 | yield* runtimeRepository.upsert({ |
| 143 | 143 | threadId, |
| 144 | providerName: "cursor", | |
| 145 | adapterKey: "cursor", | |
| 144 | providerName: "claudeAgent", | |
| 145 | adapterKey: "claudeAgent", | |
| 146 | 146 | runtimeMode: "full-access", |
| 147 | 147 | status: "running", |
| 148 | 148 | lastSeenAt: new Date().toISOString(), |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1| ... | ... | @@ -22,7 +22,7 @@ function decodeProviderKind( |
| 22 | 22 | providerName: string, |
| 23 | 23 | operation: string, |
| 24 | 24 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { |
| 25 | if (providerName === "codex") { | |
| 25 | if (providerName === "codex" || providerName === "claudeAgent") { | |
| 26 | 26 | return Effect.succeed(providerName); |
| 27 | 27 | } |
| 28 | 28 | 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 | */ | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | ||
| 15 | import type { ProviderAdapterError } from "../Errors.ts"; | |
| 16 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | |
| 17 | ||
| 18 | /** | |
| 19 | * ClaudeAdapterShape - Service API for the Claude Agent provider adapter. | |
| 20 | */ | |
| 21 | export interface ClaudeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | |
| 22 | readonly provider: "claudeAgent"; | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * ClaudeAdapter - Service tag for Claude Agent provider adapter operations. | |
| 27 | */ | |
| 28 | export 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 @@ |
| 1 | 1 | /** |
| 2 | 2 | * ProviderHealth - Provider readiness snapshot service. |
| 3 | 3 | * |
| 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. | |
| 6 | 6 | * |
| 7 | 7 | * @module ProviderHealth |
| 8 | 8 | */ |
| ... | ... | @@ -12,7 +12,7 @@ import type { Effect } from "effect"; |
| 12 | 12 | |
| 13 | 13 | export interface ProviderHealthShape { |
| 14 | 14 | /** |
| 15 | * Read provider health statuses computed at server startup. | |
| 15 | * Read the latest provider health statuses. | |
| 16 | 16 | */ |
| 17 | 17 | readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>; |
| 18 | 18 | } |
apps/server/src/serverLayers.ts+5| ... | ... | @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers |
| 19 | 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; |
| 20 | 20 | import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; |
| 21 | 21 | import { ProviderUnsupportedError } from "./provider/Errors"; |
| 22 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; | |
| 22 | 23 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 23 | 24 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 24 | 25 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| ... | ... | @@ -58,8 +59,12 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 58 | 59 | const codexAdapterLayer = makeCodexAdapterLive( |
| 59 | 60 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 60 | 61 | ); |
| 62 | const claudeAdapterLayer = makeClaudeAdapterLive( | |
| 63 | nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 64 | ); | |
| 61 | 65 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 62 | 66 | Layer.provide(codexAdapterLayer), |
| 67 | Layer.provide(claudeAdapterLayer), | |
| 63 | 68 | Layer.provideMerge(providerSessionDirectoryLayer), |
| 64 | 69 | ); |
| 65 | 70 | return makeProviderServiceLive( |
apps/web/src/appSettings.test.ts+48| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 5 | AppSettingsSchema, | |
| 4 | 6 | DEFAULT_TIMESTAMP_FORMAT, |
| 5 | 7 | getAppModelOptions, |
| 6 | 8 | normalizeCustomModelSlugs, |
| ... | ... | @@ -20,6 +22,13 @@ describe("normalizeCustomModelSlugs", () => { |
| 20 | 22 | ]), |
| 21 | 23 | ).toEqual(["custom/internal-model"]); |
| 22 | 24 | }); |
| 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 | }); | |
| 23 | 32 | }); |
| 24 | 33 | |
| 25 | 34 | describe("getAppModelOptions", () => { |
| ... | ... | @@ -46,6 +55,13 @@ describe("getAppModelOptions", () => { |
| 46 | 55 | isCustom: true, |
| 47 | 56 | }); |
| 48 | 57 | }); |
| 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 | }); | |
| 49 | 65 | }); |
| 50 | 66 | |
| 51 | 67 | describe("resolveAppModelSelection", () => { |
| ... | ... | @@ -65,3 +81,35 @@ describe("timestamp format defaults", () => { |
| 65 | 81 | expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); |
| 66 | 82 | }); |
| 67 | 83 | }); |
| 84 | ||
| 85 | describe("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 | ||
| 93 | describe("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"; |
| 3 | 3 | import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; |
| 4 | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | 5 | import { useLocalStorage } from "./hooks/useLocalStorage"; |
| 6 | import { EnvMode } from "./components/BranchToolbar.logic"; | |
| 6 | 7 | |
| 7 | 8 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 8 | 9 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 9 | 10 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 10 | export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; | |
| 11 | export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; | |
| 11 | ||
| 12 | export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); | |
| 13 | export type TimestampFormat = typeof TimestampFormat.Type; | |
| 12 | 14 | export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; |
| 15 | ||
| 13 | 16 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { |
| 14 | 17 | codex: new Set(getModelOptions("codex").map((option) => option.slug)), |
| 18 | claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), | |
| 15 | 19 | }; |
| 16 | 20 | |
| 17 | const 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 | ), | |
| 21 | const 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 | ||
| 34 | export 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(() => [])), | |
| 37 | 43 | textGenerationModel: Schema.optional(TrimmedNonEmptyString), |
| 38 | 44 | }); |
| 39 | 45 | export type AppSettings = typeof AppSettingsSchema.Type; |
| ... | ... | @@ -74,6 +80,13 @@ export function normalizeCustomModelSlugs( |
| 74 | 80 | return normalizedModels; |
| 75 | 81 | } |
| 76 | 82 | |
| 83 | function normalizeAppSettings(settings: AppSettings): AppSettings { | |
| 84 | return { | |
| 85 | ...settings, | |
| 86 | customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), | |
| 87 | customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), | |
| 88 | }; | |
| 89 | } | |
| 77 | 90 | export function getAppModelOptions( |
| 78 | 91 | provider: ProviderKind, |
| 79 | 92 | customModels: readonly string[], |
| ... | ... | @@ -152,10 +165,7 @@ export function useAppSettings() { |
| 152 | 165 | |
| 153 | 166 | const updateSettings = useCallback( |
| 154 | 167 | (patch: Partial<AppSettings>) => { |
| 155 | setSettings((prev) => ({ | |
| 156 | ...prev, | |
| 157 | ...patch, | |
| 158 | })); | |
| 168 | setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); | |
| 159 | 169 | }, |
| 160 | 170 | [setSettings], |
| 161 | 171 | ); |
apps/web/src/components/BranchToolbar.logic.ts+3-1| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | import { Schema } from "effect"; | |
| 2 | 3 | |
| 3 | export type EnvMode = "local" | "worktree"; | |
| 4 | export const EnvMode = Schema.Literals(["local", "worktree"]); | |
| 5 | export type EnvMode = typeof EnvMode.Type; | |
| 4 | 6 | |
| 5 | 7 | export function resolveEffectiveEnvMode(input: { |
| 6 | 8 | activeWorktreePath: string | null; |
apps/web/src/components/ChatView.logic.ts+2| ... | ... | @@ -123,9 +123,11 @@ export function cloneComposerImageForRetry( |
| 123 | 123 | |
| 124 | 124 | export function getCustomModelOptionsByProvider(settings: { |
| 125 | 125 | customCodexModels: readonly string[]; |
| 126 | customClaudeModels: readonly string[]; | |
| 126 | 127 | }): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { |
| 127 | 128 | return { |
| 128 | 129 | codex: getAppModelOptions("codex", settings.customCodexModels), |
| 130 | claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), | |
| 129 | 131 | }; |
| 130 | 132 | } |
| 131 | 133 |
apps/web/src/components/ChatView.tsx+522-412| ... | ... | @@ -1,32 +1,38 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ApprovalRequestId, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | type EditorId, | |
| 5 | type KeybindingCommand, | |
| 6 | type CodexReasoningEffort, | |
| 4 | type ClaudeCodeEffort, | |
| 7 | 5 | type MessageId, |
| 8 | type ProjectId, | |
| 9 | type ProjectEntry, | |
| 10 | 6 | type ProjectScript, |
| 11 | 7 | type ModelSlug, |
| 8 | type ProviderKind, | |
| 9 | type ProjectEntry, | |
| 10 | type ProjectId, | |
| 11 | type ProviderApprovalDecision, | |
| 12 | 12 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 13 | 13 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 14 | 14 | type ResolvedKeybindingsConfig, |
| 15 | type ProviderApprovalDecision, | |
| 16 | 15 | type ServerProviderStatus, |
| 17 | type ProviderKind, | |
| 18 | 16 | type ThreadId, |
| 19 | 17 | type TurnId, |
| 18 | type EditorId, | |
| 19 | type KeybindingCommand, | |
| 20 | 20 | OrchestrationThreadActivity, |
| 21 | RuntimeMode, | |
| 22 | 21 | ProviderInteractionMode, |
| 22 | RuntimeMode, | |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import { |
| 25 | applyClaudePromptEffortPrefix, | |
| 25 | 26 | getDefaultModel, |
| 26 | 27 | getDefaultReasoningEffort, |
| 27 | 28 | getReasoningEffortOptions, |
| 29 | isClaudeUltrathinkPrompt, | |
| 30 | normalizeClaudeModelOptions, | |
| 31 | normalizeCodexModelOptions, | |
| 28 | 32 | normalizeModelSlug, |
| 33 | resolveReasoningEffortForProvider, | |
| 29 | 34 | resolveModelSlugForProvider, |
| 35 | supportsClaudeUltrathinkKeyword, | |
| 30 | 36 | } from "@t3tools/shared/model"; |
| 31 | 37 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 32 | 38 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| ... | ... | @@ -53,6 +59,7 @@ import { |
| 53 | 59 | deriveTimelineEntries, |
| 54 | 60 | deriveActiveWorkStartedAt, |
| 55 | 61 | deriveActivePlanState, |
| 62 | findSidebarProposedPlan, | |
| 56 | 63 | findLatestProposedPlan, |
| 57 | 64 | deriveWorkLogEntries, |
| 58 | 65 | hasActionableProposedPlan, |
| ... | ... | @@ -146,7 +153,8 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded |
| 146 | 153 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; |
| 147 | 154 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; |
| 148 | 155 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; |
| 149 | import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; | |
| 156 | import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; | |
| 157 | import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; | |
| 150 | 158 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; |
| 151 | 159 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; |
| 152 | 160 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; |
| ... | ... | @@ -181,6 +189,17 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 181 | 189 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 182 | 190 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; |
| 183 | 191 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 192 | ||
| 193 | function 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 | } | |
| 184 | 203 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 185 | 204 | const SCRIPT_TERMINAL_COLS = 120; |
| 186 | 205 | const SCRIPT_TERMINAL_ROWS = 30; |
| ... | ... | @@ -255,8 +274,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 255 | 274 | const setComposerDraftInteractionMode = useComposerDraftStore( |
| 256 | 275 | (store) => store.setInteractionMode, |
| 257 | 276 | ); |
| 258 | const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); | |
| 259 | const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); | |
| 260 | 277 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| 261 | 278 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 262 | 279 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); |
| ... | ... | @@ -579,7 +596,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 579 | 596 | selectedProvider, |
| 580 | 597 | activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), |
| 581 | 598 | ); |
| 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]; | |
| 583 | 607 | const selectedModel = useMemo(() => { |
| 584 | 608 | const draftModel = composerDraft.model; |
| 585 | 609 | if (!draftModel) { |
| ... | ... | @@ -591,21 +615,53 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 591 | 615 | draftModel, |
| 592 | 616 | ) as ModelSlug; |
| 593 | 617 | }, [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; | |
| 599 | 651 | 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; | |
| 602 | 655 | } |
| 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]); | |
| 609 | 665 | const providerOptionsForDispatch = useMemo(() => { |
| 610 | 666 | if (!settings.codexBinaryPath && !settings.codexHomePath) { |
| 611 | 667 | return undefined; |
| ... | ... | @@ -714,6 +770,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 714 | 770 | activeLatestTurn?.turnId ?? null, |
| 715 | 771 | ); |
| 716 | 772 | }, [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 | ); | |
| 717 | 783 | const activePlan = useMemo( |
| 718 | 784 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 719 | 785 | [activeLatestTurn?.turnId, threadActivities], |
| ... | ... | @@ -1554,7 +1620,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1554 | 1620 | const togglePlanSidebar = useCallback(() => { |
| 1555 | 1621 | setPlanSidebarOpen((open) => { |
| 1556 | 1622 | if (open) { |
| 1557 | const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; | |
| 1623 | const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; | |
| 1558 | 1624 | if (turnKey) { |
| 1559 | 1625 | planSidebarDismissedForTurnRef.current = turnKey; |
| 1560 | 1626 | } |
| ... | ... | @@ -1563,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1563 | 1629 | } |
| 1564 | 1630 | return !open; |
| 1565 | 1631 | }); |
| 1566 | }, [activePlan?.turnId, activeProposedPlan?.turnId]); | |
| 1632 | }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); | |
| 1567 | 1633 | |
| 1568 | 1634 | const persistThreadSettingsForNextTurn = useCallback( |
| 1569 | 1635 | async (input: { |
| ... | ... | @@ -2361,7 +2427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2361 | 2427 | ? parseStandaloneComposerSlashCommand(trimmed) |
| 2362 | 2428 | : null; |
| 2363 | 2429 | if (standaloneSlashCommand) { |
| 2364 | await handleInteractionModeChange(standaloneSlashCommand); | |
| 2430 | handleInteractionModeChange(standaloneSlashCommand); | |
| 2365 | 2431 | promptRef.current = ""; |
| 2366 | 2432 | clearComposerDraftContent(activeThread.id); |
| 2367 | 2433 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -2414,6 +2480,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2414 | 2480 | ); |
| 2415 | 2481 | const messageIdForSend = newMessageId(); |
| 2416 | 2482 | const messageCreatedAt = new Date().toISOString(); |
| 2483 | const outgoingMessageText = formatOutgoingPrompt({ | |
| 2484 | provider: selectedProvider, | |
| 2485 | effort: selectedPromptEffort, | |
| 2486 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 2487 | }); | |
| 2417 | 2488 | const turnAttachmentsPromise = Promise.all( |
| 2418 | 2489 | composerImagesSnapshot.map(async (image) => ({ |
| 2419 | 2490 | type: "image" as const, |
| ... | ... | @@ -2436,7 +2507,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2436 | 2507 | { |
| 2437 | 2508 | id: messageIdForSend, |
| 2438 | 2509 | role: "user", |
| 2439 | text: messageTextForSend, | |
| 2510 | text: outgoingMessageText, | |
| 2440 | 2511 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 2441 | 2512 | createdAt: messageCreatedAt, |
| 2442 | 2513 | streaming: false, |
| ... | ... | @@ -2587,7 +2658,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2587 | 2658 | message: { |
| 2588 | 2659 | messageId: messageIdForSend, |
| 2589 | 2660 | role: "user", |
| 2590 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 2661 | text: outgoingMessageText, | |
| 2591 | 2662 | attachments: turnAttachments, |
| 2592 | 2663 | }, |
| 2593 | 2664 | model: selectedModel || undefined, |
| ... | ... | @@ -2830,6 +2901,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2830 | 2901 | const threadIdForSend = activeThread.id; |
| 2831 | 2902 | const messageIdForSend = newMessageId(); |
| 2832 | 2903 | const messageCreatedAt = new Date().toISOString(); |
| 2904 | const outgoingMessageText = formatOutgoingPrompt({ | |
| 2905 | provider: selectedProvider, | |
| 2906 | effort: selectedPromptEffort, | |
| 2907 | text: trimmed, | |
| 2908 | }); | |
| 2833 | 2909 | |
| 2834 | 2910 | sendInFlightRef.current = true; |
| 2835 | 2911 | beginSendPhase("sending-turn"); |
| ... | ... | @@ -2839,7 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2839 | 2915 | { |
| 2840 | 2916 | id: messageIdForSend, |
| 2841 | 2917 | role: "user", |
| 2842 | text: trimmed, | |
| 2918 | text: outgoingMessageText, | |
| 2843 | 2919 | createdAt: messageCreatedAt, |
| 2844 | 2920 | streaming: false, |
| 2845 | 2921 | }, |
| ... | ... | @@ -2867,7 +2943,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2867 | 2943 | message: { |
| 2868 | 2944 | messageId: messageIdForSend, |
| 2869 | 2945 | role: "user", |
| 2870 | text: trimmed, | |
| 2946 | text: outgoingMessageText, | |
| 2871 | 2947 | attachments: [], |
| 2872 | 2948 | }, |
| 2873 | 2949 | provider: selectedProvider, |
| ... | ... | @@ -2920,6 +2996,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2920 | 2996 | persistThreadSettingsForNextTurn, |
| 2921 | 2997 | resetSendPhase, |
| 2922 | 2998 | runtimeMode, |
| 2999 | selectedPromptEffort, | |
| 2923 | 3000 | selectedModel, |
| 2924 | 3001 | selectedModelOptionsForDispatch, |
| 2925 | 3002 | providerOptionsForDispatch, |
| ... | ... | @@ -2949,6 +3026,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2949 | 3026 | const nextThreadId = newThreadId(); |
| 2950 | 3027 | const planMarkdown = activeProposedPlan.planMarkdown; |
| 2951 | 3028 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); |
| 3029 | const outgoingImplementationPrompt = formatOutgoingPrompt({ | |
| 3030 | provider: selectedProvider, | |
| 3031 | effort: selectedPromptEffort, | |
| 3032 | text: implementationPrompt, | |
| 3033 | }); | |
| 2952 | 3034 | const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); |
| 2953 | 3035 | const nextThreadModel: ModelSlug = |
| 2954 | 3036 | selectedModel || |
| ... | ... | @@ -2985,7 +3067,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2985 | 3067 | message: { |
| 2986 | 3068 | messageId: newMessageId(), |
| 2987 | 3069 | role: "user", |
| 2988 | text: implementationPrompt, | |
| 3070 | text: outgoingImplementationPrompt, | |
| 2989 | 3071 | attachments: [], |
| 2990 | 3072 | }, |
| 2991 | 3073 | provider: selectedProvider, |
| ... | ... | @@ -3043,6 +3125,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3043 | 3125 | navigate, |
| 3044 | 3126 | resetSendPhase, |
| 3045 | 3127 | runtimeMode, |
| 3128 | selectedPromptEffort, | |
| 3046 | 3129 | selectedModel, |
| 3047 | 3130 | selectedModelOptionsForDispatch, |
| 3048 | 3131 | providerOptionsForDispatch, |
| ... | ... | @@ -3061,32 +3144,34 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3061 | 3144 | setComposerDraftProvider(activeThread.id, provider); |
| 3062 | 3145 | setComposerDraftModel( |
| 3063 | 3146 | activeThread.id, |
| 3064 | resolveAppModelSelection(provider, settings.customCodexModels, model), | |
| 3147 | resolveAppModelSelection(provider, customModelsByProvider[provider], model), | |
| 3065 | 3148 | ); |
| 3066 | 3149 | scheduleComposerFocus(); |
| 3067 | 3150 | }, |
| 3068 | 3151 | [ |
| 3069 | 3152 | activeThread, |
| 3153 | customModelsByProvider, | |
| 3070 | 3154 | lockedProvider, |
| 3071 | 3155 | scheduleComposerFocus, |
| 3072 | 3156 | setComposerDraftModel, |
| 3073 | 3157 | setComposerDraftProvider, |
| 3074 | settings.customCodexModels, | |
| 3075 | 3158 | ], |
| 3076 | 3159 | ); |
| 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)); | |
| 3087 | 3172 | scheduleComposerFocus(); |
| 3088 | 3173 | }, |
| 3089 | [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], | |
| 3174 | [scheduleComposerFocus, setPrompt], | |
| 3090 | 3175 | ); |
| 3091 | 3176 | const onEnvModeChange = useCallback( |
| 3092 | 3177 | (mode: DraftThreadEnvMode) => { |
| ... | ... | @@ -3519,65 +3604,71 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3519 | 3604 | data-chat-composer-form="true" |
| 3520 | 3605 | > |
| 3521 | 3606 | <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 | )} | |
| 3525 | 3611 | onDragEnter={onComposerDragEnter} |
| 3526 | 3612 | onDragOver={onComposerDragOver} |
| 3527 | 3613 | onDragLeave={onComposerDragLeave} |
| 3528 | 3614 | onDrop={onComposerDrop} |
| 3529 | 3615 | > |
| 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 */} | |
| 3558 | 3616 | <div |
| 3559 | 3617 | 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]" : "", | |
| 3562 | 3621 | )} |
| 3563 | 3622 | > |
| 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} | |
| 3574 | 3628 | /> |
| 3575 | 3629 | </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 | )} | |
| 3577 | 3668 | |
| 3578 | {!isComposerApprovalState && pendingUserInputs.length === 0 && ( | |
| 3579 | <> | |
| 3580 | {composerImages.length > 0 && ( | |
| 3669 | {!isComposerApprovalState && | |
| 3670 | pendingUserInputs.length === 0 && | |
| 3671 | composerImages.length > 0 && ( | |
| 3581 | 3672 | <div className="mb-3 flex flex-wrap gap-2"> |
| 3582 | 3673 | {composerImages.map((image) => ( |
| 3583 | 3674 | <div |
| ... | ... | @@ -3644,355 +3735,374 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3644 | 3735 | ))} |
| 3645 | 3736 | </div> |
| 3646 | 3737 | )} |
| 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} | |
| 3691 | 3770 | /> |
| 3692 | 3771 | </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 | ) : ( | |
| 3703 | 3785 | <div |
| 3786 | data-chat-composer-footer="true" | |
| 3704 | 3787 | 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", | |
| 3706 | 3789 | 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", | |
| 3709 | 3792 | )} |
| 3710 | 3793 | > |
| 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} | |
| 3736 | 3811 | /> |
| 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 | /> | |
| 3759 | 3812 | |
| 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 | |
| 3770 | 3831 | } |
| 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} | |
| 3781 | 3835 | /> |
| 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} | |
| 3782 | 3859 | |
| 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 | /> | |
| 3832 | 3864 | |
| 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 ? ( | |
| 3846 | 3865 | <Button |
| 3866 | variant="ghost" | |
| 3867 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 3847 | 3868 | 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 | } | |
| 3852 | 3876 | > |
| 3853 | Previous | |
| 3877 | <BotIcon /> | |
| 3878 | <span className="sr-only sm:not-sr-only"> | |
| 3879 | {interactionMode === "plan" ? "Plan" : "Chat"} | |
| 3880 | </span> | |
| 3854 | 3881 | </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} | |
| 3894 | 3965 | <Button |
| 3895 | 3966 | type="submit" |
| 3896 | 3967 | 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 | } | |
| 3899 | 3975 | > |
| 3900 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} | |
| 3976 | {activePendingIsResponding | |
| 3977 | ? "Submitting..." | |
| 3978 | : activePendingProgress.isLastQuestion | |
| 3979 | ? "Submit answers" | |
| 3980 | : "Next question"} | |
| 3901 | 3981 | </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 ? ( | |
| 3904 | 4003 | <Button |
| 3905 | 4004 | type="submit" |
| 3906 | 4005 | 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" | |
| 3908 | 4007 | disabled={isSendBusy || isConnecting} |
| 3909 | 4008 | > |
| 3910 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} | |
| 4009 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} | |
| 3911 | 4010 | </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} | |
| 3923 | 4018 | > |
| 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 | } | |
| 3930 | 4032 | > |
| 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> | |
| 3936 | 4100 | ) |
| 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> | |
| 3993 | 4103 | </div> |
| 3994 | </div> | |
| 3995 | )} | |
| 4104 | )} | |
| 4105 | </div> | |
| 3996 | 4106 | </div> |
| 3997 | 4107 | </form> |
| 3998 | 4108 | </div> |
| ... | ... | @@ -4029,14 +4139,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4029 | 4139 | {planSidebarOpen ? ( |
| 4030 | 4140 | <PlanSidebar |
| 4031 | 4141 | activePlan={activePlan} |
| 4032 | activeProposedPlan={activeProposedPlan} | |
| 4142 | activeProposedPlan={sidebarProposedPlan} | |
| 4033 | 4143 | markdownCwd={gitCwd ?? undefined} |
| 4034 | 4144 | workspaceRoot={activeProject?.cwd ?? undefined} |
| 4035 | 4145 | timestampFormat={timestampFormat} |
| 4036 | 4146 | onClose={() => { |
| 4037 | 4147 | setPlanSidebarOpen(false); |
| 4038 | 4148 | // 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; | |
| 4040 | 4150 | if (turnKey) { |
| 4041 | 4151 | planSidebarDismissedForTurnRef.current = turnKey; |
| 4042 | 4152 | } |
apps/web/src/components/Icons.tsx+1-1| ... | ... | @@ -146,7 +146,7 @@ export const OpenAI: Icon = (props) => ( |
| 146 | 146 | export const ClaudeAI: Icon = (props) => ( |
| 147 | 147 | <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"> |
| 148 | 148 | <path |
| 149 | fill="#D97757" | |
| 149 | fill="currentColor" | |
| 150 | 150 | 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" |
| 151 | 151 | /> |
| 152 | 152 | </svg> |
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { ThreadId } from "@t3tools/contracts"; | |
| 4 | import { page } from "vitest/browser"; | |
| 5 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 6 | import { render } from "vitest-browser-react"; | |
| 7 | ||
| 8 | import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; | |
| 9 | import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 10 | ||
| 11 | async 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 | ||
| 66 | describe("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 @@ |
| 1 | import { | |
| 2 | type ClaudeCodeEffort, | |
| 3 | type ClaudeModelOptions, | |
| 4 | type ProviderModelOptions, | |
| 5 | type ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | applyClaudePromptEffortPrefix, | |
| 9 | getDefaultReasoningEffort, | |
| 10 | getReasoningEffortOptions, | |
| 11 | normalizeClaudeModelOptions, | |
| 12 | resolveReasoningEffortForProvider, | |
| 13 | supportsClaudeFastMode, | |
| 14 | supportsClaudeThinkingToggle, | |
| 15 | supportsClaudeUltrathinkKeyword, | |
| 16 | isClaudeUltrathinkPrompt, | |
| 17 | } from "@t3tools/shared/model"; | |
| 18 | import { memo, useState } from "react"; | |
| 19 | import { ChevronDownIcon } from "lucide-react"; | |
| 20 | import { Button } from "../ui/button"; | |
| 21 | import { | |
| 22 | Menu, | |
| 23 | MenuGroup, | |
| 24 | MenuPopup, | |
| 25 | MenuRadioGroup, | |
| 26 | MenuRadioItem, | |
| 27 | MenuSeparator as MenuDivider, | |
| 28 | MenuTrigger, | |
| 29 | } from "../ui/menu"; | |
| 30 | import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; | |
| 31 | ||
| 32 | const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = { | |
| 33 | low: "Low", | |
| 34 | medium: "Medium", | |
| 35 | high: "High", | |
| 36 | max: "Max", | |
| 37 | ultrathink: "Ultrathink", | |
| 38 | }; | |
| 39 | ||
| 40 | const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; | |
| 41 | ||
| 42 | function 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 | ||
| 81 | function 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 | ||
| 203 | export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl); | |
| 204 | ||
| 205 | export 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 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import { page } from "vitest/browser"; | |
| 5 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 6 | import { render } from "vitest-browser-react"; | |
| 7 | ||
| 8 | import { CodexTraitsPicker } from "./CodexTraitsPicker"; | |
| 9 | import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore"; | |
| 10 | ||
| 11 | async 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 | ||
| 55 | describe("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 @@ |
| 1 | import { type CodexReasoningEffort } from "@t3tools/contracts"; | |
| 2 | import { getDefaultReasoningEffort } from "@t3tools/shared/model"; | |
| 1 | import type { | |
| 2 | CodexModelOptions, | |
| 3 | CodexReasoningEffort, | |
| 4 | ProviderModelOptions, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | getDefaultReasoningEffort, | |
| 9 | getReasoningEffortOptions, | |
| 10 | normalizeCodexModelOptions, | |
| 11 | resolveReasoningEffortForProvider, | |
| 12 | } from "@t3tools/shared/model"; | |
| 3 | 13 | import { memo, useState } from "react"; |
| 4 | 14 | import { ChevronDownIcon } from "lucide-react"; |
| 15 | import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; | |
| 5 | 16 | import { Button } from "../ui/button"; |
| 6 | 17 | import { |
| 7 | 18 | Menu, |
| ... | ... | @@ -13,22 +24,101 @@ import { |
| 13 | 24 | MenuTrigger, |
| 14 | 25 | } from "../ui/menu"; |
| 15 | 26 | |
| 16 | export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { | |
| 27 | const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = { | |
| 28 | low: "Low", | |
| 29 | medium: "Medium", | |
| 30 | high: "High", | |
| 31 | xhigh: "Extra High", | |
| 32 | }; | |
| 33 | ||
| 34 | function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): { | |
| 17 | 35 | effort: CodexReasoningEffort; |
| 18 | 36 | fastModeEnabled: boolean; |
| 19 | options: ReadonlyArray<CodexReasoningEffort>; | |
| 20 | onEffortChange: (effort: CodexReasoningEffort) => void; | |
| 21 | onFastModeChange: (enabled: boolean) => void; | |
| 22 | }) { | |
| 23 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 37 | } { | |
| 24 | 38 | 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 | ||
| 47 | function 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); | |
| 30 | 63 | }; |
| 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 | ||
| 113 | export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); | |
| 114 | ||
| 115 | export 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(" · "); | |
| 32 | 122 | |
| 33 | 123 | return ( |
| 34 | 124 | <Menu |
| ... | ... | @@ -47,53 +137,12 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { |
| 47 | 137 | } |
| 48 | 138 | > |
| 49 | 139 | <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} | |
| 61 | 141 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> |
| 62 | 142 | </span> |
| 63 | 143 | </MenuTrigger> |
| 64 | 144 | <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} /> | |
| 97 | 146 | </MenuPopup> |
| 98 | 147 | </Menu> |
| 99 | 148 | ); |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx created+186| ... | ... | @@ -0,0 +1,186 @@ |
| 1 | import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; | |
| 2 | import "../../index.css"; | |
| 3 | ||
| 4 | import { page } from "vitest/browser"; | |
| 5 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 6 | import { render } from "vitest-browser-react"; | |
| 7 | ||
| 8 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | |
| 9 | import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker"; | |
| 10 | import { CodexTraitsMenuContent } from "./CodexTraitsPicker"; | |
| 11 | import { useComposerDraftStore } from "../../composerDraftStore"; | |
| 12 | ||
| 13 | async 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 | ||
| 76 | describe("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 @@ |
| 1 | import { | |
| 2 | type CodexReasoningEffort, | |
| 3 | type ProviderKind, | |
| 4 | RuntimeMode, | |
| 5 | ProviderInteractionMode, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { getDefaultReasoningEffort } from "@t3tools/shared/model"; | |
| 8 | import { memo } from "react"; | |
| 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; | |
| 2 | import { memo, type ReactNode } from "react"; | |
| 9 | 3 | import { EllipsisIcon, ListTodoIcon } from "lucide-react"; |
| 10 | 4 | import { Button } from "../ui/button"; |
| 11 | 5 | import { |
| 12 | 6 | Menu, |
| 13 | MenuGroup, | |
| 14 | 7 | MenuItem, |
| 15 | 8 | MenuPopup, |
| 16 | 9 | MenuRadioGroup, |
| ... | ... | @@ -24,24 +17,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 24 | 17 | interactionMode: ProviderInteractionMode; |
| 25 | 18 | planSidebarOpen: boolean; |
| 26 | 19 | 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; | |
| 33 | 21 | onToggleInteractionMode: () => void; |
| 34 | 22 | onTogglePlanSidebar: () => void; |
| 35 | 23 | onToggleRuntimeMode: () => void; |
| 36 | 24 | }) { |
| 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 | ||
| 45 | 25 | return ( |
| 46 | 26 | <Menu> |
| 47 | 27 | <MenuTrigger |
| ... | ... | @@ -57,70 +37,35 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 57 | 37 | <EllipsisIcon aria-hidden="true" className="size-4" /> |
| 58 | 38 | </MenuTrigger> |
| 59 | 39 | <MenuPopup align="start"> |
| 60 | {props.selectedProvider === "codex" && props.selectedEffort != null ? ( | |
| 40 | {props.traitsMenuContent ? ( | |
| 61 | 41 | <> |
| 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} | |
| 94 | 43 | <MenuDivider /> |
| 95 | 44 | </> |
| 96 | 45 | ) : 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> | |
| 110 | 57 | <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> | |
| 124 | 69 | {props.activePlan ? ( |
| 125 | 70 | <> |
| 126 | 71 | <MenuDivider /> |
apps/web/src/components/chat/ProviderHealthBanner.tsx+10-5| ... | ... | @@ -12,18 +12,23 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ |
| 12 | 12 | return null; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | const providerLabel = | |
| 16 | status.provider === "codex" | |
| 17 | ? "Codex" | |
| 18 | : status.provider === "claudeAgent" | |
| 19 | ? "Claude" | |
| 20 | : status.provider; | |
| 15 | 21 | const defaultMessage = |
| 16 | 22 | 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`; | |
| 19 | 26 | |
| 20 | 27 | return ( |
| 21 | 28 | <div className="pt-3 mx-auto max-w-3xl"> |
| 22 | 29 | <Alert variant={status.status === "error" ? "error" : "warning"}> |
| 23 | 30 | <CircleAlertIcon /> |
| 24 | <AlertTitle> | |
| 25 | {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} | |
| 26 | </AlertTitle> | |
| 31 | <AlertTitle>{title}</AlertTitle> | |
| 27 | 32 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> |
| 28 | 33 | {status.message ?? defaultMessage} |
| 29 | 34 | </AlertDescription> |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { page } from "vitest/browser"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 4 | import { render } from "vitest-browser-react"; | |
| 5 | ||
| 6 | import { ProviderModelPicker } from "./ProviderModelPicker"; | |
| 7 | ||
| 8 | const 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 | ||
| 20 | async 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 | ||
| 48 | describe("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 |
| 25 | 25 | label: string; |
| 26 | 26 | available: true; |
| 27 | 27 | } { |
| 28 | return option.available && option.value !== "claudeCode"; | |
| 28 | return option.available; | |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | function resolveModelForProviderPicker( |
| ... | ... | @@ -63,7 +63,7 @@ function resolveModelForProviderPicker( |
| 63 | 63 | |
| 64 | 64 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { |
| 65 | 65 | codex: OpenAI, |
| 66 | claudeCode: ClaudeAI, | |
| 66 | claudeAgent: ClaudeAI, | |
| 67 | 67 | cursor: CursorIcon, |
| 68 | 68 | }; |
| 69 | 69 | |
| ... | ... | @@ -74,20 +74,41 @@ const COMING_SOON_PROVIDER_OPTIONS = [ |
| 74 | 74 | { id: "gemini", label: "Gemini", icon: Gemini }, |
| 75 | 75 | ] as const; |
| 76 | 76 | |
| 77 | function providerIconClassName( | |
| 78 | provider: ProviderKind | ProviderPickerKind, | |
| 79 | fallbackClassName: string, | |
| 80 | ): string { | |
| 81 | return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; | |
| 82 | } | |
| 83 | ||
| 77 | 84 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 78 | 85 | provider: ProviderKind; |
| 79 | 86 | model: ModelSlug; |
| 80 | 87 | lockedProvider: ProviderKind | null; |
| 81 | 88 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; |
| 89 | ultrathinkActive?: boolean; | |
| 82 | 90 | compact?: boolean; |
| 83 | 91 | disabled?: boolean; |
| 84 | 92 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; |
| 85 | 93 | }) { |
| 86 | 94 | 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]; | |
| 88 | 97 | const selectedModelLabel = |
| 89 | 98 | 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 | }; | |
| 91 | 112 | |
| 92 | 113 | return ( |
| 93 | 114 | <Menu |
| ... | ... | @@ -119,90 +140,106 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 119 | 140 | props.compact ? "max-w-36" : undefined, |
| 120 | 141 | )} |
| 121 | 142 | > |
| 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 | /> | |
| 123 | 153 | <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span> |
| 124 | 154 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> |
| 125 | 155 | </span> |
| 126 | 156 | </MenuTrigger> |
| 127 | 157 | <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)} | |
| 164 | 196 | > |
| 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 | )} | |
| 206 | 243 | </MenuPopup> |
| 207 | 244 | </Menu> |
| 208 | 245 | ); |
apps/web/src/composerDraftStore.test.ts+122-9| ... | ... | @@ -1,16 +1,19 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 1 | 2 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 4 | |
| 4 | 5 | import { |
| 6 | COMPOSER_DRAFT_STORAGE_KEY, | |
| 5 | 7 | type ComposerImageAttachment, |
| 6 | createDebouncedStorage, | |
| 7 | 8 | useComposerDraftStore, |
| 8 | 9 | } from "./composerDraftStore"; |
| 10 | import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; | |
| 9 | 11 | import { |
| 10 | 12 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 11 | 13 | insertInlineTerminalContextPlaceholder, |
| 12 | 14 | type TerminalContextDraft, |
| 13 | 15 | } from "./lib/terminalContext"; |
| 16 | import { createDebouncedStorage } from "./lib/storage"; | |
| 14 | 17 | |
| 15 | 18 | function makeImage(input: { |
| 16 | 19 | id: string; |
| ... | ... | @@ -183,6 +186,63 @@ describe("composerDraftStore clearComposerContent", () => { |
| 183 | 186 | }); |
| 184 | 187 | }); |
| 185 | 188 | |
| 189 | describe("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 | ||
| 186 | 246 | describe("composerDraftStore terminal contexts", () => { |
| 187 | 247 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 188 | 248 | |
| ... | ... | @@ -323,6 +383,37 @@ describe("composerDraftStore terminal contexts", () => { |
| 323 | 383 | }, |
| 324 | 384 | ]); |
| 325 | 385 | }); |
| 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 | }); | |
| 326 | 417 | }); |
| 327 | 418 | |
| 328 | 419 | describe("composerDraftStore project draft thread mapping", () => { |
| ... | ... | @@ -504,8 +595,8 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 504 | 595 | }); |
| 505 | 596 | }); |
| 506 | 597 | |
| 507 | describe("composerDraftStore codex fast mode", () => { | |
| 508 | const threadId = ThreadId.makeUnsafe("thread-service-tier"); | |
| 598 | describe("composerDraftStore modelOptions", () => { | |
| 599 | const threadId = ThreadId.makeUnsafe("thread-model-options"); | |
| 509 | 600 | |
| 510 | 601 | beforeEach(() => { |
| 511 | 602 | useComposerDraftStore.setState({ |
| ... | ... | @@ -515,17 +606,39 @@ describe("composerDraftStore codex fast mode", () => { |
| 515 | 606 | }); |
| 516 | 607 | }); |
| 517 | 608 | |
| 518 | it("stores codex fast mode in the draft", () => { | |
| 609 | it("stores provider-scoped model options in the draft", () => { | |
| 519 | 610 | 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 | }); | |
| 521 | 620 | |
| 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 | }); | |
| 523 | 630 | }); |
| 524 | 631 | |
| 525 | it("clears codex fast mode when reset to the default", () => { | |
| 632 | it("drops default-only model options from the draft", () => { | |
| 526 | 633 | 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 | }); | |
| 529 | 642 | |
| 530 | 643 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); |
| 531 | 644 | }); |
apps/web/src/composerDraftStore.ts+331-258| ... | ... | @@ -1,60 +1,40 @@ |
| 1 | 1 | import { |
| 2 | CODEX_REASONING_EFFORT_OPTIONS, | |
| 3 | type ClaudeCodeEffort, | |
| 4 | type CodexReasoningEffort, | |
| 2 | 5 | DEFAULT_REASONING_EFFORT_BY_PROVIDER, |
| 3 | 6 | ProjectId, |
| 4 | REASONING_EFFORT_OPTIONS_BY_PROVIDER, | |
| 7 | ProviderInteractionMode, | |
| 8 | ProviderKind, | |
| 9 | ProviderModelOptions, | |
| 10 | RuntimeMode, | |
| 5 | 11 | ThreadId, |
| 6 | type CodexReasoningEffort, | |
| 7 | type ProviderKind, | |
| 8 | type ProviderInteractionMode, | |
| 9 | type RuntimeMode, | |
| 10 | 12 | } from "@t3tools/contracts"; |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import { DeepMutable } from "effect/Types"; | |
| 11 | 15 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 16 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | |
| 12 | 17 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 13 | 18 | import { |
| 14 | 19 | type TerminalContextDraft, |
| 15 | 20 | ensureInlineTerminalContextPlaceholders, |
| 16 | 21 | normalizeTerminalContextText, |
| 17 | 22 | } from "./lib/terminalContext"; |
| 18 | import { Debouncer } from "@tanstack/react-pacer"; | |
| 19 | 23 | import { create } from "zustand"; |
| 20 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; | |
| 24 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 25 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; | |
| 21 | 26 | |
| 22 | 27 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 23 | export type DraftThreadEnvMode = "local" | "worktree"; | |
| 28 | const COMPOSER_DRAFT_STORAGE_VERSION = 2; | |
| 29 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); | |
| 30 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; | |
| 24 | 31 | |
| 25 | 32 | const COMPOSER_PERSIST_DEBOUNCE_MS = 300; |
| 26 | 33 | |
| 27 | interface DebouncedStorage extends StateStorage { | |
| 28 | flush: () => void; | |
| 29 | } | |
| 30 | ||
| 31 | export 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 | ||
| 54 | const composerDebouncedStorage: DebouncedStorage = | |
| 55 | typeof localStorage !== "undefined" | |
| 56 | ? createDebouncedStorage(localStorage) | |
| 57 | : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; | |
| 34 | const composerDebouncedStorage = createDebouncedStorage( | |
| 35 | typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), | |
| 36 | COMPOSER_PERSIST_DEBOUNCE_MS, | |
| 37 | ); | |
| 58 | 38 | |
| 59 | 39 | // Flush pending composer draft writes before page unload to prevent data loss. |
| 60 | 40 | if (typeof window !== "undefined") { |
| ... | ... | @@ -63,57 +43,74 @@ if (typeof window !== "undefined") { |
| 63 | 43 | }); |
| 64 | 44 | } |
| 65 | 45 | |
| 66 | export interface PersistedComposerImageAttachment { | |
| 67 | id: string; | |
| 68 | name: string; | |
| 69 | mimeType: string; | |
| 70 | sizeBytes: number; | |
| 71 | dataUrl: string; | |
| 72 | } | |
| 46 | export 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 | }); | |
| 53 | export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; | |
| 73 | 54 | |
| 74 | 55 | export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { |
| 75 | 56 | previewUrl: string; |
| 76 | 57 | file: File; |
| 77 | 58 | } |
| 78 | 59 | |
| 79 | interface PersistedTerminalContextDraft { | |
| 80 | id: string; | |
| 81 | threadId: ThreadId; | |
| 82 | createdAt: string; | |
| 83 | terminalId: string; | |
| 84 | terminalLabel: string; | |
| 85 | lineStart: number; | |
| 86 | lineEnd: number; | |
| 87 | } | |
| 60 | const 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 | }); | |
| 69 | type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; | |
| 88 | 70 | |
| 89 | interface 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 | } | |
| 71 | const 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 | }); | |
| 81 | type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; | |
| 101 | 82 | |
| 102 | interface 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 | } | |
| 83 | const 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 | }); | |
| 88 | type LegacyCodexFields = typeof LegacyCodexFields.Type; | |
| 111 | 89 | |
| 112 | interface PersistedComposerDraftStoreState { | |
| 113 | draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; | |
| 114 | draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>; | |
| 115 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; | |
| 116 | } | |
| 90 | type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields; | |
| 91 | ||
| 92 | const 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 | }); | |
| 101 | type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; | |
| 102 | ||
| 103 | const PersistedComposerDraftStoreState = Schema.Struct({ | |
| 104 | draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), | |
| 105 | draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), | |
| 106 | projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), | |
| 107 | }); | |
| 108 | type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; | |
| 109 | ||
| 110 | const PersistedComposerDraftStoreStorage = Schema.Struct({ | |
| 111 | version: Schema.Number, | |
| 112 | state: PersistedComposerDraftStoreState, | |
| 113 | }); | |
| 117 | 114 | |
| 118 | 115 | interface ComposerThreadDraftState { |
| 119 | 116 | prompt: string; |
| ... | ... | @@ -123,10 +120,9 @@ interface ComposerThreadDraftState { |
| 123 | 120 | terminalContexts: TerminalContextDraft[]; |
| 124 | 121 | provider: ProviderKind | null; |
| 125 | 122 | model: string | null; |
| 123 | modelOptions: ProviderModelOptions | null; | |
| 126 | 124 | runtimeMode: RuntimeMode | null; |
| 127 | 125 | interactionMode: ProviderInteractionMode | null; |
| 128 | effort: CodexReasoningEffort | null; | |
| 129 | codexFastMode: boolean; | |
| 130 | 126 | } |
| 131 | 127 | |
| 132 | 128 | export interface DraftThreadState { |
| ... | ... | @@ -180,13 +176,15 @@ interface ComposerDraftStoreState { |
| 180 | 176 | setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; |
| 181 | 177 | setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; |
| 182 | 178 | setModel: (threadId: ThreadId, model: string | null | undefined) => void; |
| 179 | setModelOptions: ( | |
| 180 | threadId: ThreadId, | |
| 181 | modelOptions: ProviderModelOptions | null | undefined, | |
| 182 | ) => void; | |
| 183 | 183 | setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; |
| 184 | 184 | setInteractionMode: ( |
| 185 | 185 | threadId: ThreadId, |
| 186 | 186 | interactionMode: ProviderInteractionMode | null | undefined, |
| 187 | 187 | ) => void; |
| 188 | setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void; | |
| 189 | setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void; | |
| 190 | 188 | addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; |
| 191 | 189 | addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; |
| 192 | 190 | removeImage: (threadId: ThreadId, imageId: string) => void; |
| ... | ... | @@ -222,8 +220,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; |
| 222 | 220 | Object.freeze(EMPTY_IMAGES); |
| 223 | 221 | Object.freeze(EMPTY_IDS); |
| 224 | 222 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 225 | Object.freeze(EMPTY_TERMINAL_CONTEXTS); | |
| 226 | const EMPTY_THREAD_DRAFT = Object.freeze({ | |
| 223 | const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ | |
| 227 | 224 | prompt: "", |
| 228 | 225 | images: EMPTY_IMAGES, |
| 229 | 226 | nonPersistedImageIds: EMPTY_IDS, |
| ... | ... | @@ -231,15 +228,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ |
| 231 | 228 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, |
| 232 | 229 | provider: null, |
| 233 | 230 | model: null, |
| 231 | modelOptions: null, | |
| 234 | 232 | runtimeMode: null, |
| 235 | 233 | interactionMode: null, |
| 236 | effort: null, | |
| 237 | codexFastMode: false, | |
| 238 | }) as ComposerThreadDraftState; | |
| 239 | ||
| 240 | const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>( | |
| 241 | REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, | |
| 242 | ); | |
| 234 | }); | |
| 243 | 235 | |
| 244 | 236 | function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 245 | 237 | return { |
| ... | ... | @@ -250,10 +242,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 250 | 242 | terminalContexts: [], |
| 251 | 243 | provider: null, |
| 252 | 244 | model: null, |
| 245 | modelOptions: null, | |
| 253 | 246 | runtimeMode: null, |
| 254 | 247 | interactionMode: null, |
| 255 | effort: null, | |
| 256 | codexFastMode: false, | |
| 257 | 248 | }; |
| 258 | 249 | } |
| 259 | 250 | |
| ... | ... | @@ -322,15 +313,88 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 322 | 313 | draft.terminalContexts.length === 0 && |
| 323 | 314 | draft.provider === null && |
| 324 | 315 | draft.model === null && |
| 316 | draft.modelOptions === null && | |
| 325 | 317 | draft.runtimeMode === null && |
| 326 | draft.interactionMode === null && | |
| 327 | draft.effort === null && | |
| 328 | draft.codexFastMode === false | |
| 318 | draft.interactionMode === null | |
| 329 | 319 | ); |
| 330 | 320 | } |
| 331 | 321 | |
| 332 | 322 | function normalizeProviderKind(value: unknown): ProviderKind | null { |
| 333 | return value === "codex" ? value : null; | |
| 323 | return value === "codex" || value === "claudeAgent" ? value : null; | |
| 324 | } | |
| 325 | ||
| 326 | function 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 | }; | |
| 334 | 398 | } |
| 335 | 399 | |
| 336 | 400 | function revokeObjectPreviewUrl(previewUrl: string): void { |
| ... | ... | @@ -429,15 +493,14 @@ function normalizeDraftThreadEnvMode( |
| 429 | 493 | return fallbackWorktreePath ? "worktree" : "local"; |
| 430 | 494 | } |
| 431 | 495 | |
| 432 | function 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"] = {}; | |
| 496 | function normalizePersistedDraftThreads( | |
| 497 | rawDraftThreadsByThreadId: unknown, | |
| 498 | rawProjectDraftThreadIdByProjectId: unknown, | |
| 499 | ): Pick< | |
| 500 | PersistedComposerDraftStoreState, | |
| 501 | "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" | |
| 502 | > { | |
| 503 | const draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState> = {}; | |
| 441 | 504 | if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { |
| 442 | 505 | for (const [threadId, rawDraftThread] of Object.entries( |
| 443 | 506 | rawDraftThreadsByThreadId as Record<string, unknown>, |
| ... | ... | @@ -479,8 +542,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 479 | 542 | }; |
| 480 | 543 | } |
| 481 | 544 | } |
| 482 | const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = | |
| 483 | {}; | |
| 545 | ||
| 546 | const projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = {}; | |
| 484 | 547 | if ( |
| 485 | 548 | rawProjectDraftThreadIdByProjectId && |
| 486 | 549 | typeof rawProjectDraftThreadIdByProjectId === "object" |
| ... | ... | @@ -514,10 +577,23 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 514 | 577 | } |
| 515 | 578 | } |
| 516 | 579 | } |
| 580 | ||
| 581 | return { draftThreadsByThreadId, projectDraftThreadIdByProjectId }; | |
| 582 | } | |
| 583 | ||
| 584 | function normalizePersistedDraftsByThreadId( | |
| 585 | rawDraftMap: unknown, | |
| 586 | resolveModelOptions: ( | |
| 587 | draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState, | |
| 588 | provider: ProviderKind | null, | |
| 589 | ) => ProviderModelOptions | null, | |
| 590 | ): PersistedComposerDraftStoreState["draftsByThreadId"] { | |
| 517 | 591 | if (!rawDraftMap || typeof rawDraftMap !== "object") { |
| 518 | return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; | |
| 592 | return {}; | |
| 519 | 593 | } |
| 520 | const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; | |
| 594 | ||
| 595 | const nextDraftsByThreadId: DeepMutable<PersistedComposerDraftStoreState["draftsByThreadId"]> = | |
| 596 | {}; | |
| 521 | 597 | for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) { |
| 522 | 598 | if (typeof threadId !== "string" || threadId.length === 0) { |
| 523 | 599 | continue; |
| ... | ... | @@ -525,7 +601,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 525 | 601 | if (!draftValue || typeof draftValue !== "object") { |
| 526 | 602 | continue; |
| 527 | 603 | } |
| 528 | const draftCandidate = draftValue as Record<string, unknown>; | |
| 604 | const draftCandidate = draftValue as | |
| 605 | | PersistedComposerThreadDraftState | |
| 606 | | LegacyPersistedCodexThreadDraftState; | |
| 529 | 607 | const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; |
| 530 | 608 | const attachments = Array.isArray(draftCandidate.attachments) |
| 531 | 609 | ? draftCandidate.attachments.flatMap((entry) => { |
| ... | ... | @@ -553,15 +631,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 553 | 631 | draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" |
| 554 | 632 | ? draftCandidate.interactionMode |
| 555 | 633 | : 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); | |
| 565 | 635 | const prompt = ensureInlineTerminalContextPlaceholders( |
| 566 | 636 | promptCandidate, |
| 567 | 637 | terminalContexts.length, |
| ... | ... | @@ -572,10 +642,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 572 | 642 | terminalContexts.length === 0 && |
| 573 | 643 | !provider && |
| 574 | 644 | !model && |
| 645 | modelOptions === null && | |
| 575 | 646 | !runtimeMode && |
| 576 | !interactionMode && | |
| 577 | !effort && | |
| 578 | !codexFastMode | |
| 647 | !interactionMode | |
| 579 | 648 | ) { |
| 580 | 649 | continue; |
| 581 | 650 | } |
| ... | ... | @@ -585,32 +654,120 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 585 | 654 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 586 | 655 | ...(provider ? { provider } : {}), |
| 587 | 656 | ...(model ? { model } : {}), |
| 657 | ...(modelOptions ? { modelOptions } : {}), | |
| 588 | 658 | ...(runtimeMode ? { runtimeMode } : {}), |
| 589 | 659 | ...(interactionMode ? { interactionMode } : {}), |
| 590 | ...(effort ? { effort } : {}), | |
| 591 | ...(codexFastMode ? { codexFastMode } : {}), | |
| 592 | 660 | }; |
| 593 | 661 | } |
| 662 | ||
| 663 | return nextDraftsByThreadId; | |
| 664 | } | |
| 665 | ||
| 666 | function 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 | ); | |
| 594 | 690 | return { |
| 595 | draftsByThreadId: nextDraftsByThreadId, | |
| 691 | draftsByThreadId, | |
| 596 | 692 | draftThreadsByThreadId, |
| 597 | 693 | projectDraftThreadIdByProjectId, |
| 598 | 694 | }; |
| 599 | 695 | } |
| 600 | 696 | |
| 601 | function 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); | |
| 697 | function 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; | |
| 609 | 706 | } |
| 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 | ||
| 750 | function normalizeCurrentPersistedComposerDraftStoreState( | |
| 751 | persistedState: unknown, | |
| 752 | ): PersistedComposerDraftStoreState { | |
| 753 | if (!persistedState || typeof persistedState !== "object") { | |
| 612 | 754 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 613 | 755 | } |
| 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 | }; | |
| 614 | 771 | } |
| 615 | 772 | |
| 616 | 773 | function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { |
| ... | ... | @@ -618,9 +775,14 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { |
| 618 | 775 | return []; |
| 619 | 776 | } |
| 620 | 777 | 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( | |
| 624 | 786 | (attachment) => attachment.id, |
| 625 | 787 | ); |
| 626 | 788 | } catch { |
| ... | ... | @@ -661,7 +823,7 @@ function hydreatePersistedComposerImageAttachment( |
| 661 | 823 | } |
| 662 | 824 | |
| 663 | 825 | function hydrateImagesFromPersisted( |
| 664 | attachments: PersistedComposerImageAttachment[], | |
| 826 | attachments: ReadonlyArray<PersistedComposerImageAttachment>, | |
| 665 | 827 | ): ComposerImageAttachment[] { |
| 666 | 828 | return attachments.flatMap((attachment) => { |
| 667 | 829 | const file = hydreatePersistedComposerImageAttachment(attachment); |
| ... | ... | @@ -688,7 +850,7 @@ function toHydratedThreadDraft( |
| 688 | 850 | prompt: persistedDraft.prompt, |
| 689 | 851 | images: hydrateImagesFromPersisted(persistedDraft.attachments), |
| 690 | 852 | nonPersistedImageIds: [], |
| 691 | persistedAttachments: persistedDraft.attachments, | |
| 853 | persistedAttachments: [...persistedDraft.attachments], | |
| 692 | 854 | terminalContexts: |
| 693 | 855 | persistedDraft.terminalContexts?.map((context) => ({ |
| 694 | 856 | ...context, |
| ... | ... | @@ -696,10 +858,9 @@ function toHydratedThreadDraft( |
| 696 | 858 | })) ?? [], |
| 697 | 859 | provider: persistedDraft.provider ?? null, |
| 698 | 860 | model: persistedDraft.model ?? null, |
| 861 | modelOptions: persistedDraft.modelOptions ?? null, | |
| 699 | 862 | runtimeMode: persistedDraft.runtimeMode ?? null, |
| 700 | 863 | interactionMode: persistedDraft.interactionMode ?? null, |
| 701 | effort: persistedDraft.effort ?? null, | |
| 702 | codexFastMode: persistedDraft.codexFastMode === true, | |
| 703 | 864 | }; |
| 704 | 865 | } |
| 705 | 866 | |
| ... | ... | @@ -1018,9 +1179,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1018 | 1179 | if (threadId.length === 0) { |
| 1019 | 1180 | return; |
| 1020 | 1181 | } |
| 1021 | const normalizedModel = normalizeModelSlug(model) ?? null; | |
| 1022 | 1182 | set((state) => { |
| 1023 | 1183 | const existing = state.draftsByThreadId[threadId]; |
| 1184 | const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; | |
| 1024 | 1185 | if (!existing && normalizedModel === null) { |
| 1025 | 1186 | return state; |
| 1026 | 1187 | } |
| ... | ... | @@ -1041,24 +1202,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1041 | 1202 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1042 | 1203 | }); |
| 1043 | 1204 | }, |
| 1044 | setRuntimeMode: (threadId, runtimeMode) => { | |
| 1205 | setModelOptions: (threadId, modelOptions) => { | |
| 1045 | 1206 | if (threadId.length === 0) { |
| 1046 | 1207 | return; |
| 1047 | 1208 | } |
| 1048 | const nextRuntimeMode = | |
| 1049 | runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; | |
| 1050 | 1209 | set((state) => { |
| 1051 | 1210 | 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) { | |
| 1053 | 1214 | return state; |
| 1054 | 1215 | } |
| 1055 | 1216 | const base = existing ?? createEmptyThreadDraft(); |
| 1056 | if (base.runtimeMode === nextRuntimeMode) { | |
| 1217 | if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) { | |
| 1057 | 1218 | return state; |
| 1058 | 1219 | } |
| 1059 | 1220 | const nextDraft: ComposerThreadDraftState = { |
| 1060 | 1221 | ...base, |
| 1061 | runtimeMode: nextRuntimeMode, | |
| 1222 | modelOptions: nextModelOptions, | |
| 1062 | 1223 | }; |
| 1063 | 1224 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1064 | 1225 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -1069,56 +1230,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1069 | 1230 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1070 | 1231 | }); |
| 1071 | 1232 | }, |
| 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) => { | |
| 1101 | 1234 | if (threadId.length === 0) { |
| 1102 | 1235 | return; |
| 1103 | 1236 | } |
| 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; | |
| 1110 | 1239 | set((state) => { |
| 1111 | 1240 | const existing = state.draftsByThreadId[threadId]; |
| 1112 | if (!existing && nextEffort === null) { | |
| 1241 | if (!existing && nextRuntimeMode === null) { | |
| 1113 | 1242 | return state; |
| 1114 | 1243 | } |
| 1115 | 1244 | const base = existing ?? createEmptyThreadDraft(); |
| 1116 | if (base.effort === nextEffort) { | |
| 1245 | if (base.runtimeMode === nextRuntimeMode) { | |
| 1117 | 1246 | return state; |
| 1118 | 1247 | } |
| 1119 | 1248 | const nextDraft: ComposerThreadDraftState = { |
| 1120 | 1249 | ...base, |
| 1121 | effort: nextEffort, | |
| 1250 | runtimeMode: nextRuntimeMode, | |
| 1122 | 1251 | }; |
| 1123 | 1252 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1124 | 1253 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -1129,23 +1258,24 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1129 | 1258 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1130 | 1259 | }); |
| 1131 | 1260 | }, |
| 1132 | setCodexFastMode: (threadId, enabled) => { | |
| 1261 | setInteractionMode: (threadId, interactionMode) => { | |
| 1133 | 1262 | if (threadId.length === 0) { |
| 1134 | 1263 | return; |
| 1135 | 1264 | } |
| 1136 | const nextCodexFastMode = enabled === true; | |
| 1265 | const nextInteractionMode = | |
| 1266 | interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; | |
| 1137 | 1267 | set((state) => { |
| 1138 | 1268 | const existing = state.draftsByThreadId[threadId]; |
| 1139 | if (!existing && nextCodexFastMode === false) { | |
| 1269 | if (!existing && nextInteractionMode === null) { | |
| 1140 | 1270 | return state; |
| 1141 | 1271 | } |
| 1142 | 1272 | const base = existing ?? createEmptyThreadDraft(); |
| 1143 | if (base.codexFastMode === nextCodexFastMode) { | |
| 1273 | if (base.interactionMode === nextInteractionMode) { | |
| 1144 | 1274 | return state; |
| 1145 | 1275 | } |
| 1146 | 1276 | const nextDraft: ComposerThreadDraftState = { |
| 1147 | 1277 | ...base, |
| 1148 | codexFastMode: nextCodexFastMode, | |
| 1278 | interactionMode: nextInteractionMode, | |
| 1149 | 1279 | }; |
| 1150 | 1280 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1151 | 1281 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -1496,70 +1626,13 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1496 | 1626 | }), |
| 1497 | 1627 | { |
| 1498 | 1628 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 1499 | version: 1, | |
| 1629 | version: COMPOSER_DRAFT_STORAGE_VERSION, | |
| 1500 | 1630 | 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, | |
| 1561 | 1633 | merge: (persistedState, currentState) => { |
| 1562 | const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); | |
| 1634 | const normalizedPersisted = | |
| 1635 | normalizeCurrentPersistedComposerDraftStoreState(persistedState); | |
| 1563 | 1636 | const draftsByThreadId = Object.fromEntries( |
| 1564 | 1637 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ |
| 1565 | 1638 | threadId, |
apps/web/src/index.css+63| ... | ... | @@ -404,3 +404,66 @@ label:has(> select#reasoning-effort) select { |
| 404 | 404 | overflow: clip; |
| 405 | 405 | background: color-mix(in srgb, var(--card) 92%, var(--background)); |
| 406 | 406 | } |
| 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 @@ |
| 1 | import { Debouncer } from "@tanstack/react-pacer"; | |
| 2 | ||
| 3 | export 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 | ||
| 9 | export interface DebouncedStorage<R = unknown> extends StateStorage<R> { | |
| 10 | flush: () => void; | |
| 11 | } | |
| 12 | ||
| 13 | export 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 | ||
| 26 | export 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<{ |
| 54 | 54 | placeholder: "your-codex-model-slug", |
| 55 | 55 | example: "gpt-6.7-codex-ultra-preview", |
| 56 | 56 | }, |
| 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 | }, | |
| 57 | 64 | ] as const; |
| 58 | 65 | |
| 59 | 66 | const TIMESTAMP_FORMAT_LABELS = { |
| ... | ... | @@ -67,6 +74,8 @@ function getCustomModelsForProvider( |
| 67 | 74 | provider: ProviderKind, |
| 68 | 75 | ) { |
| 69 | 76 | switch (provider) { |
| 77 | case "claudeAgent": | |
| 78 | return settings.customClaudeModels; | |
| 70 | 79 | case "codex": |
| 71 | 80 | default: |
| 72 | 81 | return settings.customCodexModels; |
| ... | ... | @@ -78,6 +87,8 @@ function getDefaultCustomModelsForProvider( |
| 78 | 87 | provider: ProviderKind, |
| 79 | 88 | ) { |
| 80 | 89 | switch (provider) { |
| 90 | case "claudeAgent": | |
| 91 | return defaults.customClaudeModels; | |
| 81 | 92 | case "codex": |
| 82 | 93 | default: |
| 83 | 94 | return defaults.customCodexModels; |
| ... | ... | @@ -86,6 +97,8 @@ function getDefaultCustomModelsForProvider( |
| 86 | 97 | |
| 87 | 98 | function patchCustomModels(provider: ProviderKind, models: string[]) { |
| 88 | 99 | switch (provider) { |
| 100 | case "claudeAgent": | |
| 101 | return { customClaudeModels: models }; | |
| 89 | 102 | case "codex": |
| 90 | 103 | default: |
| 91 | 104 | return { customCodexModels: models }; |
| ... | ... | @@ -102,6 +115,7 @@ function SettingsRouteView() { |
| 102 | 115 | Record<ProviderKind, string> |
| 103 | 116 | >({ |
| 104 | 117 | codex: "", |
| 118 | claudeAgent: "", | |
| 105 | 119 | }); |
| 106 | 120 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< |
| 107 | 121 | Partial<Record<ProviderKind, string | null>> |
apps/web/src/session-logic.test.ts+364-6| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | deriveTimelineEntries, |
| 17 | 17 | deriveWorkLogEntries, |
| 18 | 18 | findLatestProposedPlan, |
| 19 | findSidebarProposedPlan, | |
| 19 | 20 | hasActionableProposedPlan, |
| 20 | 21 | hasToolActivityForTurn, |
| 21 | 22 | isLatestTurnSettled, |
| ... | ... | @@ -141,6 +142,36 @@ describe("derivePendingApprovals", () => { |
| 141 | 142 | |
| 142 | 143 | expect(derivePendingApprovals(activities)).toEqual([]); |
| 143 | 144 | }); |
| 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 | }); | |
| 144 | 175 | }); |
| 145 | 176 | |
| 146 | 177 | describe("derivePendingUserInputs", () => { |
| ... | ... | @@ -227,6 +258,48 @@ describe("derivePendingUserInputs", () => { |
| 227 | 258 | }, |
| 228 | 259 | ]); |
| 229 | 260 | }); |
| 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 | }); | |
| 230 | 303 | }); |
| 231 | 304 | |
| 232 | 305 | describe("deriveActivePlanState", () => { |
| ... | ... | @@ -372,6 +445,103 @@ describe("hasActionableProposedPlan", () => { |
| 372 | 445 | }); |
| 373 | 446 | }); |
| 374 | 447 | |
| 448 | describe("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 | ||
| 375 | 545 | describe("deriveWorkLogEntries", () => { |
| 376 | 546 | it("omits tool started entries and keeps completed entries", () => { |
| 377 | 547 | const activities: OrchestrationThreadActivity[] = [ |
| ... | ... | @@ -459,6 +629,42 @@ describe("deriveWorkLogEntries", () => { |
| 459 | 629 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 460 | 630 | }); |
| 461 | 631 | |
| 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 | ||
| 462 | 668 | it("orders work log by activity sequence when present", () => { |
| 463 | 669 | const activities: OrchestrationThreadActivity[] = [ |
| 464 | 670 | makeActivity({ |
| ... | ... | @@ -561,6 +767,158 @@ describe("deriveWorkLogEntries", () => { |
| 561 | 767 | "apps/web/src/session-logic.ts", |
| 562 | 768 | ]); |
| 563 | 769 | }); |
| 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 | }); | |
| 564 | 922 | }); |
| 565 | 923 | |
| 566 | 924 | describe("deriveTimelineEntries", () => { |
| ... | ... | @@ -726,18 +1084,18 @@ describe("deriveActiveWorkStartedAt", () => { |
| 726 | 1084 | }); |
| 727 | 1085 | |
| 728 | 1086 | describe("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"); | |
| 731 | 1089 | const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); |
| 732 | 1090 | expect(PROVIDER_OPTIONS).toEqual([ |
| 733 | 1091 | { value: "codex", label: "Codex", available: true }, |
| 734 | { value: "claudeCode", label: "Claude Code", available: false }, | |
| 1092 | { value: "claudeAgent", label: "Claude", available: true }, | |
| 735 | 1093 | { value: "cursor", label: "Cursor", available: false }, |
| 736 | 1094 | ]); |
| 737 | 1095 | expect(claude).toEqual({ |
| 738 | value: "claudeCode", | |
| 739 | label: "Claude Code", | |
| 740 | available: false, | |
| 1096 | value: "claudeAgent", | |
| 1097 | label: "Claude", | |
| 1098 | available: true, | |
| 741 | 1099 | }); |
| 742 | 1100 | expect(cursor).toEqual({ |
| 743 | 1101 | value: "cursor", |
apps/web/src/session-logic.ts+242-62| ... | ... | @@ -15,11 +15,12 @@ import type { |
| 15 | 15 | ChatMessage, |
| 16 | 16 | ProposedPlan, |
| 17 | 17 | SessionPhase, |
| 18 | Thread, | |
| 18 | 19 | ThreadSession, |
| 19 | 20 | TurnDiffSummary, |
| 20 | 21 | } from "./types"; |
| 21 | 22 | |
| 22 | export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; | |
| 23 | export type ProviderPickerKind = ProviderKind | "cursor"; | |
| 23 | 24 | |
| 24 | 25 | export const PROVIDER_OPTIONS: Array<{ |
| 25 | 26 | value: ProviderPickerKind; |
| ... | ... | @@ -27,7 +28,7 @@ export const PROVIDER_OPTIONS: Array<{ |
| 27 | 28 | available: boolean; |
| 28 | 29 | }> = [ |
| 29 | 30 | { value: "codex", label: "Codex", available: true }, |
| 30 | { value: "claudeCode", label: "Claude Code", available: false }, | |
| 31 | { value: "claudeAgent", label: "Claude", available: true }, | |
| 31 | 32 | { value: "cursor", label: "Cursor", available: false }, |
| 32 | 33 | ]; |
| 33 | 34 | |
| ... | ... | @@ -44,6 +45,11 @@ export interface WorkLogEntry { |
| 44 | 45 | requestKind?: PendingApproval["requestKind"]; |
| 45 | 46 | } |
| 46 | 47 | |
| 48 | interface DerivedWorkLogEntry extends WorkLogEntry { | |
| 49 | activityKind: OrchestrationThreadActivity["kind"]; | |
| 50 | collapseKey?: string; | |
| 51 | } | |
| 52 | ||
| 47 | 53 | export interface PendingApproval { |
| 48 | 54 | requestId: ApprovalRequestId; |
| 49 | 55 | requestKind: "command" | "file-read" | "file-change"; |
| ... | ... | @@ -159,6 +165,20 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ |
| 159 | 165 | } |
| 160 | 166 | } |
| 161 | 167 | |
| 168 | function 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 | ||
| 162 | 182 | export function derivePendingApprovals( |
| 163 | 183 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 164 | 184 | ): PendingApproval[] { |
| ... | ... | @@ -203,7 +223,7 @@ export function derivePendingApprovals( |
| 203 | 223 | if ( |
| 204 | 224 | activity.kind === "provider.approval.respond.failed" && |
| 205 | 225 | requestId && |
| 206 | detail?.includes("Unknown pending permission request") | |
| 226 | isStalePendingRequestFailureDetail(detail) | |
| 207 | 227 | ) { |
| 208 | 228 | openByRequestId.delete(requestId); |
| 209 | 229 | continue; |
| ... | ... | @@ -279,6 +299,7 @@ export function derivePendingUserInputs( |
| 279 | 299 | payload && typeof payload.requestId === "string" |
| 280 | 300 | ? ApprovalRequestId.makeUnsafe(payload.requestId) |
| 281 | 301 | : null; |
| 302 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; | |
| 282 | 303 | |
| 283 | 304 | if (activity.kind === "user-input.requested" && requestId) { |
| 284 | 305 | const questions = parseUserInputQuestions(payload); |
| ... | ... | @@ -295,6 +316,15 @@ export function derivePendingUserInputs( |
| 295 | 316 | |
| 296 | 317 | if (activity.kind === "user-input.resolved" && requestId) { |
| 297 | 318 | 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); | |
| 298 | 328 | } |
| 299 | 329 | } |
| 300 | 330 | |
| ... | ... | @@ -377,15 +407,7 @@ export function findLatestProposedPlan( |
| 377 | 407 | ) |
| 378 | 408 | .at(-1); |
| 379 | 409 | 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); | |
| 389 | 411 | } |
| 390 | 412 | } |
| 391 | 413 | |
| ... | ... | @@ -399,15 +421,31 @@ export function findLatestProposedPlan( |
| 399 | 421 | return null; |
| 400 | 422 | } |
| 401 | 423 | |
| 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 | ||
| 427 | export 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); | |
| 411 | 449 | } |
| 412 | 450 | |
| 413 | 451 | export function hasActionableProposedPlan( |
| ... | ... | @@ -421,50 +459,168 @@ export function deriveWorkLogEntries( |
| 421 | 459 | latestTurnId: TurnId | undefined, |
| 422 | 460 | ): WorkLogEntry[] { |
| 423 | 461 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 424 | return ordered | |
| 462 | const entries = ordered | |
| 425 | 463 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 426 | 464 | .filter((activity) => activity.kind !== "tool.started") |
| 427 | 465 | .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") |
| 428 | 466 | .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 | ||
| 474 | function 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 | ||
| 486 | function 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 | ||
| 531 | function 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 | ||
| 546 | function 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 | ||
| 562 | function 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 | ||
| 586 | function 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 | ||
| 597 | function 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 | ||
| 610 | function normalizeCompactToolLabel(value: string): string { | |
| 611 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 612 | } | |
| 613 | ||
| 614 | function 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 | }; | |
| 468 | 624 | } |
| 469 | 625 | |
| 470 | 626 | function asRecord(value: unknown): Record<string, unknown> | null { |
| ... | ... | @@ -632,7 +788,31 @@ function compareActivitiesByOrder( |
| 632 | 788 | return -1; |
| 633 | 789 | } |
| 634 | 790 | |
| 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 | ||
| 805 | function 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; | |
| 636 | 816 | } |
| 637 | 817 | |
| 638 | 818 | export function hasToolActivityForTurn( |
apps/web/src/store.test.ts+24-2| ... | ... | @@ -191,7 +191,7 @@ describe("store pure functions", () => { |
| 191 | 191 | }); |
| 192 | 192 | |
| 193 | 193 | describe("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", () => { | |
| 195 | 195 | const initialState = makeState(makeThread()); |
| 196 | 196 | const readModel = makeReadModel( |
| 197 | 197 | makeReadModelThread({ |
| ... | ... | @@ -201,7 +201,29 @@ describe("store read model sync", () => { |
| 201 | 201 | |
| 202 | 202 | const next = syncServerReadModel(initialState, readModel); |
| 203 | 203 | |
| 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"); | |
| 205 | 227 | }); |
| 206 | 228 | |
| 207 | 229 | it("preserves the current project order when syncing incoming read model updates", () => { |
apps/web/src/store.ts+4-11| ... | ... | @@ -7,8 +7,7 @@ import { |
| 7 | 7 | type OrchestrationSessionStatus, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { |
| 10 | getModelOptions, | |
| 11 | normalizeModelSlug, | |
| 10 | inferProviderForModel, | |
| 12 | 11 | resolveModelSlug, |
| 13 | 12 | resolveModelSlugForProvider, |
| 14 | 13 | } from "@t3tools/shared/model"; |
| ... | ... | @@ -189,26 +188,20 @@ function toLegacySessionStatus( |
| 189 | 188 | } |
| 190 | 189 | |
| 191 | 190 | function toLegacyProvider(providerName: string | null): ProviderKind { |
| 192 | if (providerName === "codex") { | |
| 191 | if (providerName === "codex" || providerName === "claudeAgent") { | |
| 193 | 192 | return providerName; |
| 194 | 193 | } |
| 195 | 194 | return "codex"; |
| 196 | 195 | } |
| 197 | 196 | |
| 198 | const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug)); | |
| 199 | ||
| 200 | 197 | function inferProviderForThreadModel(input: { |
| 201 | 198 | readonly model: string; |
| 202 | 199 | readonly sessionProviderName: string | null; |
| 203 | 200 | }): ProviderKind { |
| 204 | if (input.sessionProviderName === "codex") { | |
| 201 | if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") { | |
| 205 | 202 | return input.sessionProviderName; |
| 206 | 203 | } |
| 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); | |
| 212 | 205 | } |
| 213 | 206 | |
| 214 | 207 | function resolveWsHttpOrigin(): string { |
apps/web/vitest.browser.config.ts+1-4| ... | ... | @@ -15,10 +15,7 @@ export default mergeConfig( |
| 15 | 15 | }, |
| 16 | 16 | }, |
| 17 | 17 | test: { |
| 18 | include: [ | |
| 19 | "src/components/ChatView.browser.tsx", | |
| 20 | "src/components/KeybindingsToast.browser.tsx", | |
| 21 | ], | |
| 18 | include: ["src/components/**/*.browser.tsx"], | |
| 22 | 19 | browser: { |
| 23 | 20 | enabled: true, |
| 24 | 21 | provider: playwright(), |
bun.lock+73-71| ... | ... | @@ -48,6 +48,7 @@ |
| 48 | 48 | "t3": "./dist/index.mjs", |
| 49 | 49 | }, |
| 50 | 50 | "dependencies": { |
| 51 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", | |
| 51 | 52 | "@effect/platform-node": "catalog:", |
| 52 | 53 | "@effect/sql-sqlite-bun": "catalog:", |
| 53 | 54 | "@pierre/diffs": "^1.1.0-beta.16", |
| ... | ... | @@ -152,6 +153,7 @@ |
| 152 | 153 | "scripts": { |
| 153 | 154 | "name": "@t3tools/scripts", |
| 154 | 155 | "dependencies": { |
| 156 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", | |
| 155 | 157 | "@effect/platform-node": "catalog:", |
| 156 | 158 | "@t3tools/contracts": "workspace:*", |
| 157 | 159 | "@t3tools/shared": "workspace:*", |
| ... | ... | @@ -182,13 +184,15 @@ |
| 182 | 184 | "vitest": "^4.0.0", |
| 183 | 185 | }, |
| 184 | 186 | "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=="], | |
| 186 | 188 | |
| 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=="], | |
| 188 | 192 | |
| 189 | 193 | "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.8.0", "", { "dependencies": { "picomatch": "^4.0.3" } }, "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w=="], |
| 190 | 194 | |
| 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=="], | |
| 192 | 196 | |
| 193 | 197 | "@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=="], |
| 194 | 198 | |
| ... | ... | @@ -548,45 +552,45 @@ |
| 548 | 552 | |
| 549 | 553 | "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="], |
| 550 | 554 | |
| 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=="], | |
| 552 | 556 | |
| 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=="], | |
| 554 | 558 | |
| 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=="], | |
| 556 | 560 | |
| 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=="], | |
| 558 | 562 | |
| 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=="], | |
| 560 | 564 | |
| 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=="], | |
| 562 | 566 | |
| 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=="], | |
| 564 | 568 | |
| 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=="], | |
| 566 | 570 | |
| 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=="], | |
| 568 | 572 | |
| 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=="], | |
| 570 | 574 | |
| 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=="], | |
| 572 | 576 | |
| 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=="], | |
| 574 | 578 | |
| 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=="], | |
| 576 | 580 | |
| 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=="], | |
| 578 | 582 | |
| 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=="], | |
| 580 | 584 | |
| 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=="], | |
| 582 | 586 | |
| 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=="], | |
| 584 | 588 | |
| 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=="], | |
| 586 | 590 | |
| 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=="], | |
| 588 | 592 | |
| 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=="], | |
| 590 | 594 | |
| 591 | 595 | "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], |
| 592 | 596 | |
| ... | ... | @@ -626,7 +630,7 @@ |
| 626 | 630 | |
| 627 | 631 | "@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=="], |
| 628 | 632 | |
| 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=="], | |
| 630 | 634 | |
| 631 | 635 | "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], |
| 632 | 636 | |
| ... | ... | @@ -700,7 +704,7 @@ |
| 700 | 704 | |
| 701 | 705 | "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], |
| 702 | 706 | |
| 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=="], | |
| 704 | 708 | |
| 705 | 709 | "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], |
| 706 | 710 | |
| ... | ... | @@ -710,25 +714,25 @@ |
| 710 | 714 | |
| 711 | 715 | "@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=="], |
| 712 | 716 | |
| 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=="], | |
| 714 | 718 | |
| 715 | 719 | "@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=="], |
| 716 | 720 | |
| 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=="], | |
| 718 | 722 | |
| 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=="], | |
| 720 | 724 | |
| 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=="], | |
| 722 | 726 | |
| 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=="], | |
| 724 | 728 | |
| 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=="], | |
| 726 | 730 | |
| 727 | 731 | "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], |
| 728 | 732 | |
| 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=="], | |
| 730 | 734 | |
| 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=="], | |
| 732 | 736 | |
| 733 | 737 | "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |
| 734 | 738 | |
| ... | ... | @@ -786,7 +790,7 @@ |
| 786 | 790 | |
| 787 | 791 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], |
| 788 | 792 | |
| 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=="], | |
| 790 | 794 | |
| 791 | 795 | "@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=="], |
| 792 | 796 | |
| ... | ... | @@ -852,7 +856,7 @@ |
| 852 | 856 | |
| 853 | 857 | "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], |
| 854 | 858 | |
| 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=="], | |
| 856 | 860 | |
| 857 | 861 | "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], |
| 858 | 862 | |
| ... | ... | @@ -866,7 +870,7 @@ |
| 866 | 870 | |
| 867 | 871 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], |
| 868 | 872 | |
| 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=="], | |
| 870 | 874 | |
| 871 | 875 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], |
| 872 | 876 | |
| ... | ... | @@ -896,7 +900,7 @@ |
| 896 | 900 | |
| 897 | 901 | "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=="], |
| 898 | 902 | |
| 899 | "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], | |
| 903 | "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], | |
| 900 | 904 | |
| 901 | 905 | "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], |
| 902 | 906 | |
| ... | ... | @@ -1156,7 +1160,7 @@ |
| 1156 | 1160 | |
| 1157 | 1161 | "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], |
| 1158 | 1162 | |
| 1159 | "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], | |
| 1163 | "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], | |
| 1160 | 1164 | |
| 1161 | 1165 | "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], |
| 1162 | 1166 | |
| ... | ... | @@ -1282,7 +1286,7 @@ |
| 1282 | 1286 | |
| 1283 | 1287 | "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], |
| 1284 | 1288 | |
| 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=="], | |
| 1286 | 1290 | |
| 1287 | 1291 | "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], |
| 1288 | 1292 | |
| ... | ... | @@ -1406,7 +1410,7 @@ |
| 1406 | 1410 | |
| 1407 | 1411 | "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=="], |
| 1408 | 1412 | |
| 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=="], | |
| 1410 | 1414 | |
| 1411 | 1415 | "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], |
| 1412 | 1416 | |
| ... | ... | @@ -1450,7 +1454,7 @@ |
| 1450 | 1454 | |
| 1451 | 1455 | "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], |
| 1452 | 1456 | |
| 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=="], | |
| 1454 | 1458 | |
| 1455 | 1459 | "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=="], |
| 1456 | 1460 | |
| ... | ... | @@ -1458,7 +1462,7 @@ |
| 1458 | 1462 | |
| 1459 | 1463 | "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=="], |
| 1460 | 1464 | |
| 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=="], | |
| 1462 | 1466 | |
| 1463 | 1467 | "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], |
| 1464 | 1468 | |
| ... | ... | @@ -1510,7 +1514,7 @@ |
| 1510 | 1514 | |
| 1511 | 1515 | "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], |
| 1512 | 1516 | |
| 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=="], | |
| 1514 | 1518 | |
| 1515 | 1519 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], |
| 1516 | 1520 | |
| ... | ... | @@ -1676,7 +1680,7 @@ |
| 1676 | 1680 | |
| 1677 | 1681 | "tinyclip": ["tinyclip@0.1.12", "", {}, "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA=="], |
| 1678 | 1682 | |
| 1679 | "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], | |
| 1683 | "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], | |
| 1680 | 1684 | |
| 1681 | 1685 | "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], |
| 1682 | 1686 | |
| ... | ... | @@ -1684,9 +1688,9 @@ |
| 1684 | 1688 | |
| 1685 | 1689 | "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], |
| 1686 | 1690 | |
| 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=="], | |
| 1688 | 1692 | |
| 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=="], | |
| 1690 | 1694 | |
| 1691 | 1695 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], |
| 1692 | 1696 | |
| ... | ... | @@ -1710,19 +1714,19 @@ |
| 1710 | 1714 | |
| 1711 | 1715 | "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=="], |
| 1712 | 1716 | |
| 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=="], | |
| 1714 | 1718 | |
| 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=="], | |
| 1716 | 1720 | |
| 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=="], | |
| 1718 | 1722 | |
| 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=="], | |
| 1720 | 1724 | |
| 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=="], | |
| 1722 | 1726 | |
| 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=="], | |
| 1724 | 1728 | |
| 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=="], | |
| 1726 | 1730 | |
| 1727 | 1731 | "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], |
| 1728 | 1732 | |
| ... | ... | @@ -1740,7 +1744,7 @@ |
| 1740 | 1744 | |
| 1741 | 1745 | "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], |
| 1742 | 1746 | |
| 1743 | "undici": ["undici@7.24.0", "", {}, "sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg=="], | |
| 1747 | "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], | |
| 1744 | 1748 | |
| 1745 | 1749 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 1746 | 1750 | |
| ... | ... | @@ -1794,21 +1798,21 @@ |
| 1794 | 1798 | |
| 1795 | 1799 | "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=="], |
| 1796 | 1800 | |
| 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=="], | |
| 1798 | 1802 | |
| 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=="], | |
| 1800 | 1804 | |
| 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=="], | |
| 1802 | 1806 | |
| 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=="], | |
| 1804 | 1808 | |
| 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=="], | |
| 1806 | 1810 | |
| 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=="], | |
| 1808 | 1812 | |
| 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=="], | |
| 1810 | 1814 | |
| 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=="], | |
| 1812 | 1816 | |
| 1813 | 1817 | "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=="], |
| 1814 | 1818 | |
| ... | ... | @@ -1856,7 +1860,7 @@ |
| 1856 | 1860 | |
| 1857 | 1861 | "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], |
| 1858 | 1862 | |
| 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=="], | |
| 1860 | 1864 | |
| 1861 | 1865 | "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=="], |
| 1862 | 1866 | |
| ... | ... | @@ -1864,7 +1868,7 @@ |
| 1864 | 1868 | |
| 1865 | 1869 | "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], |
| 1866 | 1870 | |
| 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=="], | |
| 1868 | 1872 | |
| 1869 | 1873 | "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], |
| 1870 | 1874 | |
| ... | ... | @@ -1872,7 +1876,7 @@ |
| 1872 | 1876 | |
| 1873 | 1877 | "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], |
| 1874 | 1878 | |
| 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=="], | |
| 1876 | 1880 | |
| 1877 | 1881 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 1878 | 1882 | |
| ... | ... | @@ -1898,13 +1902,13 @@ |
| 1898 | 1902 | |
| 1899 | 1903 | "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| 1900 | 1904 | |
| 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=="], | |
| 1902 | 1906 | |
| 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=="], | |
| 1904 | 1908 | |
| 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=="], | |
| 1906 | 1910 | |
| 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=="], | |
| 1908 | 1912 | |
| 1909 | 1913 | "@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=="], |
| 1910 | 1914 | |
| ... | ... | @@ -1988,8 +1992,6 @@ |
| 1988 | 1992 | |
| 1989 | 1993 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 1990 | 1994 | |
| 1991 | "yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], | |
| 1992 | ||
| 1993 | 1995 | "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], |
| 1994 | 1996 | |
| 1995 | 1997 | "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 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import { ProviderKind } from "./orchestration"; | |
| 2 | import type { ProviderKind } from "./orchestration"; | |
| 3 | 3 | |
| 4 | 4 | export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |
| 5 | 5 | export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; |
| 6 | export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; | |
| 7 | export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; | |
| 8 | export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; | |
| 6 | 9 | |
| 7 | 10 | export const CodexModelOptions = Schema.Struct({ |
| 8 | 11 | reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), |
| ... | ... | @@ -10,8 +13,16 @@ export const CodexModelOptions = Schema.Struct({ |
| 10 | 13 | }); |
| 11 | 14 | export type CodexModelOptions = typeof CodexModelOptions.Type; |
| 12 | 15 | |
| 16 | export 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 | }); | |
| 21 | export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; | |
| 22 | ||
| 13 | 23 | export const ProviderModelOptions = Schema.Struct({ |
| 14 | 24 | codex: Schema.optional(CodexModelOptions), |
| 25 | claudeAgent: Schema.optional(ClaudeModelOptions), | |
| 15 | 26 | }); |
| 16 | 27 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; |
| 17 | 28 | |
| ... | ... | @@ -29,19 +40,28 @@ export const MODEL_OPTIONS_BY_PROVIDER = { |
| 29 | 40 | { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, |
| 30 | 41 | { slug: "gpt-5.2", name: "GPT-5.2" }, |
| 31 | 42 | ], |
| 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 | ], | |
| 32 | 48 | } as const satisfies Record<ProviderKind, readonly ModelOption[]>; |
| 33 | 49 | export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; |
| 34 | 50 | |
| 35 | type BuiltInModelSlug = ModelOptionsByProvider[ProviderKind][number]["slug"]; | |
| 51 | type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; | |
| 36 | 52 | export type ModelSlug = BuiltInModelSlug | (string & {}); |
| 37 | 53 | |
| 38 | export const DEFAULT_MODEL_BY_PROVIDER = { | |
| 54 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { | |
| 39 | 55 | codex: "gpt-5.4", |
| 40 | } as const satisfies Record<ProviderKind, ModelSlug>; | |
| 56 | claudeAgent: "claude-sonnet-4-6", | |
| 57 | }; | |
| 41 | 58 | |
| 59 | // Backward compatibility for existing Codex-only call sites. | |
| 60 | export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; | |
| 61 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; | |
| 42 | 62 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; |
| 43 | 63 | |
| 44 | export const MODEL_SLUG_ALIASES_BY_PROVIDER = { | |
| 64 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = { | |
| 45 | 65 | codex: { |
| 46 | 66 | "5.4": "gpt-5.4", |
| 47 | 67 | "5.3": "gpt-5.3-codex", |
| ... | ... | @@ -49,12 +69,28 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { |
| 49 | 69 | "5.3-spark": "gpt-5.3-codex-spark", |
| 50 | 70 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 51 | 71 | }, |
| 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 | }; | |
| 53 | 87 | |
| 54 | 88 | export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { |
| 55 | 89 | 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[]>; | |
| 57 | 92 | |
| 58 | 93 | export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { |
| 59 | 94 | 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 { |
| 6 | 6 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 7 | 7 | DEFAULT_RUNTIME_MODE, |
| 8 | 8 | OrchestrationGetTurnDiffInput, |
| 9 | OrchestrationLatestTurn, | |
| 9 | 10 | OrchestrationProposedPlan, |
| 10 | 11 | OrchestrationSession, |
| 11 | 12 | ProjectCreateCommand, |
| ... | ... | @@ -22,6 +23,7 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC |
| 22 | 23 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( |
| 23 | 24 | ThreadTurnStartRequestedPayload, |
| 24 | 25 | ); |
| 26 | const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); | |
| 25 | 27 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 26 | 28 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 27 | 29 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); |
| ... | ... | @@ -247,6 +249,27 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe |
| 247 | 249 | }), |
| 248 | 250 | ); |
| 249 | 251 | |
| 252 | it.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 | ||
| 250 | 273 | it.effect("decodes orchestration session runtime mode defaults", () => |
| 251 | 274 | Effect.gen(function* () { |
| 252 | 275 | const parsed = yield* decodeOrchestrationSession({ |
packages/contracts/src/orchestration.ts+15-3| ... | ... | @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { |
| 27 | 27 | domainEvent: "orchestration.domainEvent", |
| 28 | 28 | } as const; |
| 29 | 29 | |
| 30 | export const ProviderKind = Schema.Literal("codex"); | |
| 30 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); | |
| 31 | 31 | export type ProviderKind = typeof ProviderKind.Type; |
| 32 | 32 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 33 | 33 | "untrusted", |
| ... | ... | @@ -43,13 +43,24 @@ export const ProviderSandboxMode = Schema.Literals([ |
| 43 | 43 | ]); |
| 44 | 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 45 | 45 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 46 | const CodexProviderStartOptions = Schema.Struct({ | |
| 46 | ||
| 47 | export const CodexProviderStartOptions = Schema.Struct({ | |
| 47 | 48 | binaryPath: Schema.optional(TrimmedNonEmptyString), |
| 48 | 49 | homePath: Schema.optional(TrimmedNonEmptyString), |
| 49 | 50 | }); |
| 50 | const ProviderStartOptions = Schema.Struct({ | |
| 51 | ||
| 52 | export const ClaudeProviderStartOptions = Schema.Struct({ | |
| 53 | binaryPath: Schema.optional(TrimmedNonEmptyString), | |
| 54 | permissionMode: Schema.optional(TrimmedNonEmptyString), | |
| 55 | maxThinkingTokens: Schema.optional(NonNegativeInt), | |
| 56 | }); | |
| 57 | ||
| 58 | export const ProviderStartOptions = Schema.Struct({ | |
| 51 | 59 | codex: Schema.optional(CodexProviderStartOptions), |
| 60 | claudeAgent: Schema.optional(ClaudeProviderStartOptions), | |
| 52 | 61 | }); |
| 62 | export type ProviderStartOptions = typeof ProviderStartOptions.Type; | |
| 63 | ||
| 53 | 64 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 54 | 65 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 55 | 66 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| ... | ... | @@ -254,6 +265,7 @@ export const OrchestrationLatestTurn = Schema.Struct({ |
| 254 | 265 | startedAt: Schema.NullOr(IsoDateTime), |
| 255 | 266 | completedAt: Schema.NullOr(IsoDateTime), |
| 256 | 267 | assistantMessageId: Schema.NullOr(MessageId), |
| 268 | sourceProposedPlan: Schema.optional(SourceProposedPlanReference), | |
| 257 | 269 | }); |
| 258 | 270 | export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; |
| 259 | 271 |
packages/contracts/src/provider.test.ts+48| ... | ... | @@ -42,6 +42,38 @@ describe("ProviderSessionStartInput", () => { |
| 42 | 42 | }), |
| 43 | 43 | ).toThrow(); |
| 44 | 44 | }); |
| 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 | }); | |
| 45 | 77 | }); |
| 46 | 78 | |
| 47 | 79 | describe("ProviderSendTurnInput", () => { |
| ... | ... | @@ -61,4 +93,20 @@ describe("ProviderSendTurnInput", () => { |
| 61 | 93 | expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); |
| 62 | 94 | expect(parsed.modelOptions?.codex?.fastMode).toBe(true); |
| 63 | 95 | }); |
| 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 | }); | |
| 64 | 112 | }); |
packages/contracts/src/provider.ts+10-20| ... | ... | @@ -19,11 +19,11 @@ import { |
| 19 | 19 | ProviderKind, |
| 20 | 20 | ProviderRequestKind, |
| 21 | 21 | ProviderSandboxMode, |
| 22 | ProviderStartOptions, | |
| 22 | 23 | ProviderUserInputAnswers, |
| 23 | 24 | RuntimeMode, |
| 24 | 25 | } from "./orchestration"; |
| 25 | 26 | |
| 26 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | |
| 27 | 27 | const ProviderSessionStatus = Schema.Literals([ |
| 28 | 28 | "connecting", |
| 29 | 29 | "ready", |
| ... | ... | @@ -36,32 +36,22 @@ export const ProviderSession = Schema.Struct({ |
| 36 | 36 | provider: ProviderKind, |
| 37 | 37 | status: ProviderSessionStatus, |
| 38 | 38 | runtimeMode: RuntimeMode, |
| 39 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 40 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 39 | cwd: Schema.optional(TrimmedNonEmptyString), | |
| 40 | model: Schema.optional(TrimmedNonEmptyString), | |
| 41 | 41 | threadId: ThreadId, |
| 42 | 42 | resumeCursor: Schema.optional(Schema.Unknown), |
| 43 | 43 | activeTurnId: Schema.optional(TurnId), |
| 44 | 44 | createdAt: IsoDateTime, |
| 45 | 45 | updatedAt: IsoDateTime, |
| 46 | lastError: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 46 | lastError: Schema.optional(TrimmedNonEmptyString), | |
| 47 | 47 | }); |
| 48 | 48 | export type ProviderSession = typeof ProviderSession.Type; |
| 49 | 49 | |
| 50 | const CodexProviderStartOptions = Schema.Struct({ | |
| 51 | binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 52 | homePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 53 | }); | |
| 54 | ||
| 55 | export const ProviderStartOptions = Schema.Struct({ | |
| 56 | codex: Schema.optional(CodexProviderStartOptions), | |
| 57 | }); | |
| 58 | export type ProviderStartOptions = typeof ProviderStartOptions.Type; | |
| 59 | ||
| 60 | 50 | export const ProviderSessionStartInput = Schema.Struct({ |
| 61 | 51 | threadId: ThreadId, |
| 62 | 52 | provider: Schema.optional(ProviderKind), |
| 63 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 64 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 53 | cwd: Schema.optional(TrimmedNonEmptyString), | |
| 54 | model: Schema.optional(TrimmedNonEmptyString), | |
| 65 | 55 | modelOptions: Schema.optional(ProviderModelOptions), |
| 66 | 56 | resumeCursor: Schema.optional(Schema.Unknown), |
| 67 | 57 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| ... | ... | @@ -74,12 +64,12 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; |
| 74 | 64 | export const ProviderSendTurnInput = Schema.Struct({ |
| 75 | 65 | threadId: ThreadId, |
| 76 | 66 | 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)), | |
| 78 | 68 | ), |
| 79 | 69 | attachments: Schema.optional( |
| 80 | 70 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), |
| 81 | 71 | ), |
| 82 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 72 | model: Schema.optional(TrimmedNonEmptyString), | |
| 83 | 73 | modelOptions: Schema.optional(ProviderModelOptions), |
| 84 | 74 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 85 | 75 | }); |
| ... | ... | @@ -125,8 +115,8 @@ export const ProviderEvent = Schema.Struct({ |
| 125 | 115 | provider: ProviderKind, |
| 126 | 116 | threadId: ThreadId, |
| 127 | 117 | createdAt: IsoDateTime, |
| 128 | method: TrimmedNonEmptyStringSchema, | |
| 129 | message: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 118 | method: TrimmedNonEmptyString, | |
| 119 | message: Schema.optional(TrimmedNonEmptyString), | |
| 130 | 120 | turnId: Schema.optional(TurnId), |
| 131 | 121 | itemId: Schema.optional(ProviderItemId), |
| 132 | 122 | requestId: Schema.optional(ApprovalRequestId), |
packages/contracts/src/providerRuntime.test.ts+3-3| ... | ... | @@ -10,7 +10,7 @@ describe("ProviderRuntimeEvent", () => { |
| 10 | 10 | const parsed = decodeRuntimeEvent({ |
| 11 | 11 | type: "turn.plan.updated", |
| 12 | 12 | eventId: "event-1", |
| 13 | provider: "codex", | |
| 13 | provider: "claudeAgent", | |
| 14 | 14 | sessionId: "runtime-session-1", |
| 15 | 15 | createdAt: "2026-02-28T00:00:00.000Z", |
| 16 | 16 | threadId: "thread-1", |
| ... | ... | @@ -56,7 +56,7 @@ describe("ProviderRuntimeEvent", () => { |
| 56 | 56 | const parsed = decodeRuntimeEvent({ |
| 57 | 57 | type: "user-input.requested", |
| 58 | 58 | eventId: "event-2", |
| 59 | provider: "codex", | |
| 59 | provider: "claudeAgent", | |
| 60 | 60 | sessionId: "runtime-session-2", |
| 61 | 61 | createdAt: "2026-02-28T00:00:01.000Z", |
| 62 | 62 | threadId: "thread-2", |
| ... | ... | @@ -94,7 +94,7 @@ describe("ProviderRuntimeEvent", () => { |
| 94 | 94 | const parsed = decodeRuntimeEvent({ |
| 95 | 95 | type: "user-input.resolved", |
| 96 | 96 | eventId: "event-3", |
| 97 | provider: "codex", | |
| 97 | provider: "claudeAgent", | |
| 98 | 98 | sessionId: "runtime-session-2", |
| 99 | 99 | createdAt: "2026-02-28T00:00:02.000Z", |
| 100 | 100 | threadId: "thread-2", |
packages/contracts/src/providerRuntime.ts+7-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import { Option, Schema } from "effect"; | |
| 2 | 2 | import { |
| 3 | 3 | EventId, |
| 4 | 4 | IsoDateTime, |
| ... | ... | @@ -19,6 +19,8 @@ const RuntimeEventRawSource = Schema.Literals([ |
| 19 | 19 | "codex.app-server.notification", |
| 20 | 20 | "codex.app-server.request", |
| 21 | 21 | "codex.eventmsg", |
| 22 | "claude.sdk.message", | |
| 23 | "claude.sdk.permission", | |
| 22 | 24 | "codex.sdk.thread-event", |
| 23 | 25 | ]); |
| 24 | 26 | export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; |
| ... | ... | @@ -411,6 +413,9 @@ export const UserInputQuestion = Schema.Struct({ |
| 411 | 413 | header: TrimmedNonEmptyStringSchema, |
| 412 | 414 | question: TrimmedNonEmptyStringSchema, |
| 413 | 415 | options: Schema.Array(UserInputQuestionOption), |
| 416 | multiSelect: Schema.optional(Schema.Boolean).pipe( | |
| 417 | Schema.withConstructorDefault(() => Option.some(false)), | |
| 418 | ), | |
| 414 | 419 | }); |
| 415 | 420 | export type UserInputQuestion = typeof UserInputQuestion.Type; |
| 416 | 421 | |
| ... | ... | @@ -434,6 +439,7 @@ export type TaskStartedPayload = typeof TaskStartedPayload.Type; |
| 434 | 439 | const TaskProgressPayload = Schema.Struct({ |
| 435 | 440 | taskId: RuntimeTaskId, |
| 436 | 441 | description: TrimmedNonEmptyStringSchema, |
| 442 | summary: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 437 | 443 | usage: Schema.optional(Schema.Unknown), |
| 438 | 444 | lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), |
| 439 | 445 | }); |
packages/shared/src/model.test.ts+222-10| ... | ... | @@ -1,13 +1,33 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { DEFAULT_MODEL_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER } from "@t3tools/contracts"; | |
| 2 | import { | |
| 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"; | |
| 3 | 10 | |
| 4 | 11 | import { |
| 12 | applyClaudePromptEffortPrefix, | |
| 13 | getEffectiveClaudeCodeEffort, | |
| 5 | 14 | getDefaultModel, |
| 6 | 15 | getDefaultReasoningEffort, |
| 7 | 16 | getModelOptions, |
| 8 | 17 | getReasoningEffortOptions, |
| 18 | inferProviderForModel, | |
| 19 | isClaudeUltrathinkPrompt, | |
| 20 | normalizeClaudeModelOptions, | |
| 21 | normalizeCodexModelOptions, | |
| 9 | 22 | normalizeModelSlug, |
| 23 | resolveReasoningEffortForProvider, | |
| 10 | 24 | resolveModelSlug, |
| 25 | resolveModelSlugForProvider, | |
| 26 | supportsClaudeAdaptiveReasoning, | |
| 27 | supportsClaudeFastMode, | |
| 28 | supportsClaudeMaxEffort, | |
| 29 | supportsClaudeThinkingToggle, | |
| 30 | supportsClaudeUltrathinkKeyword, | |
| 11 | 31 | } from "./model"; |
| 12 | 32 | |
| 13 | 33 | describe("normalizeModelSlug", () => { |
| ... | ... | @@ -32,38 +52,230 @@ describe("normalizeModelSlug", () => { |
| 32 | 52 | expect(normalizeModelSlug("toString")).toBe("toString"); |
| 33 | 53 | expect(normalizeModelSlug("constructor")).toBe("constructor"); |
| 34 | 54 | }); |
| 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 | }); | |
| 35 | 61 | }); |
| 36 | 62 | |
| 37 | 63 | describe("resolveModelSlug", () => { |
| 38 | 64 | 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); | |
| 41 | 67 | }); |
| 42 | 68 | |
| 43 | 69 | 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); | |
| 46 | 72 | }); |
| 47 | 73 | |
| 48 | 74 | it("resolves only supported model options", () => { |
| 49 | for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { | |
| 75 | for (const model of MODEL_OPTIONS) { | |
| 50 | 76 | expect(resolveModelSlug(model.slug)).toBe(model.slug); |
| 51 | 77 | } |
| 52 | 78 | }); |
| 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 | ||
| 53 | 90 | 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); | |
| 56 | 94 | }); |
| 57 | 95 | }); |
| 58 | 96 | |
| 59 | 97 | describe("getReasoningEffortOptions", () => { |
| 60 | 98 | 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 | ||
| 126 | describe("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"); | |
| 62 | 140 | }); |
| 63 | 141 | }); |
| 64 | 142 | |
| 65 | 143 | describe("getDefaultReasoningEffort", () => { |
| 66 | 144 | 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 | ||
| 152 | describe("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 | ||
| 164 | describe("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 | ||
| 179 | describe("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 | ||
| 191 | describe("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 | ||
| 206 | describe("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 | ||
| 228 | describe("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 | ||
| 237 | describe("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 | ||
| 246 | describe("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 | ||
| 256 | describe("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 | ||
| 264 | describe("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 | ||
| 274 | describe("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); | |
| 68 | 280 | }); |
| 69 | 281 | }); |
packages/shared/src/model.ts+182-12| ... | ... | @@ -1,19 +1,29 @@ |
| 1 | 1 | import { |
| 2 | CLAUDE_CODE_EFFORT_OPTIONS, | |
| 2 | 3 | CODEX_REASONING_EFFORT_OPTIONS, |
| 3 | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| 5 | DEFAULT_REASONING_EFFORT_BY_PROVIDER, | |
| 4 | 6 | MODEL_OPTIONS_BY_PROVIDER, |
| 5 | 7 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 8 | REASONING_EFFORT_OPTIONS_BY_PROVIDER, | |
| 9 | type ClaudeModelOptions, | |
| 10 | type ClaudeCodeEffort, | |
| 11 | type CodexModelOptions, | |
| 6 | 12 | type CodexReasoningEffort, |
| 7 | 13 | type ModelSlug, |
| 14 | type ProviderReasoningEffort, | |
| 8 | 15 | type ProviderKind, |
| 9 | 16 | } from "@t3tools/contracts"; |
| 10 | 17 | |
| 11 | type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; | |
| 12 | ||
| 13 | const MODEL_SLUG_SET_BY_PROVIDER: Record<CatalogProvider, ReadonlySet<ModelSlug>> = { | |
| 18 | const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = { | |
| 19 | claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), | |
| 14 | 20 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), |
| 15 | 21 | }; |
| 16 | 22 | |
| 23 | const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; | |
| 24 | const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; | |
| 25 | const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; | |
| 26 | ||
| 17 | 27 | export function getModelOptions(provider: ProviderKind = "codex") { |
| 18 | 28 | return MODEL_OPTIONS_BY_PROVIDER[provider]; |
| 19 | 29 | } |
| ... | ... | @@ -22,6 +32,31 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { |
| 22 | 32 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 23 | 33 | } |
| 24 | 34 | |
| 35 | export function supportsClaudeFastMode(model: string | null | undefined): boolean { | |
| 36 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; | |
| 37 | } | |
| 38 | ||
| 39 | export 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 | ||
| 44 | export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { | |
| 45 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; | |
| 46 | } | |
| 47 | ||
| 48 | export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { | |
| 49 | return supportsClaudeAdaptiveReasoning(model); | |
| 50 | } | |
| 51 | ||
| 52 | export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { | |
| 53 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL; | |
| 54 | } | |
| 55 | ||
| 56 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { | |
| 57 | return typeof text === "string" && /\bultrathink\b/i.test(text); | |
| 58 | } | |
| 59 | ||
| 25 | 60 | export function normalizeModelSlug( |
| 26 | 61 | model: string | null | undefined, |
| 27 | 62 | provider: ProviderKind = "codex", |
| ... | ... | @@ -36,7 +71,9 @@ export function normalizeModelSlug( |
| 36 | 71 | } |
| 37 | 72 | |
| 38 | 73 | 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; | |
| 40 | 77 | return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); |
| 41 | 78 | } |
| 42 | 79 | |
| ... | ... | @@ -46,12 +83,12 @@ export function resolveModelSlug( |
| 46 | 83 | ): ModelSlug { |
| 47 | 84 | const normalized = normalizeModelSlug(model, provider); |
| 48 | 85 | if (!normalized) { |
| 49 | return getDefaultModel(provider); | |
| 86 | return DEFAULT_MODEL_BY_PROVIDER[provider]; | |
| 50 | 87 | } |
| 51 | 88 | |
| 52 | 89 | return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) |
| 53 | 90 | ? normalized |
| 54 | : getDefaultModel(provider); | |
| 91 | : DEFAULT_MODEL_BY_PROVIDER[provider]; | |
| 55 | 92 | } |
| 56 | 93 | |
| 57 | 94 | export function resolveModelSlugForProvider( |
| ... | ... | @@ -61,18 +98,151 @@ export function resolveModelSlugForProvider( |
| 61 | 98 | return resolveModelSlug(model, provider); |
| 62 | 99 | } |
| 63 | 100 | |
| 101 | export 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 | ||
| 118 | export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray<CodexReasoningEffort>; | |
| 119 | export function getReasoningEffortOptions( | |
| 120 | provider: "claudeAgent", | |
| 121 | model?: string | null | undefined, | |
| 122 | ): ReadonlyArray<ClaudeCodeEffort>; | |
| 123 | export function getReasoningEffortOptions( | |
| 124 | provider?: ProviderKind, | |
| 125 | model?: string | null | undefined, | |
| 126 | ): ReadonlyArray<ProviderReasoningEffort>; | |
| 64 | 127 | export function getReasoningEffortOptions( |
| 65 | 128 | 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]; | |
| 68 | 141 | } |
| 69 | 142 | |
| 70 | 143 | export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; |
| 71 | export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null; | |
| 144 | export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; | |
| 145 | export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; | |
| 72 | 146 | export function getDefaultReasoningEffort( |
| 73 | 147 | provider: ProviderKind = "codex", |
| 74 | ): CodexReasoningEffort | null { | |
| 75 | return provider === "codex" ? "high" : null; | |
| 148 | ): ProviderReasoningEffort { | |
| 149 | return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; | |
| 150 | } | |
| 151 | ||
| 152 | export function resolveReasoningEffortForProvider( | |
| 153 | provider: "codex", | |
| 154 | effort: string | null | undefined, | |
| 155 | ): CodexReasoningEffort | null; | |
| 156 | export function resolveReasoningEffortForProvider( | |
| 157 | provider: "claudeAgent", | |
| 158 | effort: string | null | undefined, | |
| 159 | ): ClaudeCodeEffort | null; | |
| 160 | export function resolveReasoningEffortForProvider( | |
| 161 | provider: ProviderKind, | |
| 162 | effort: string | null | undefined, | |
| 163 | ): ProviderReasoningEffort | null; | |
| 164 | export 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 | ||
| 181 | export 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 | ||
| 190 | export 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 | ||
| 205 | export 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 | ||
| 231 | export 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}`; | |
| 76 | 246 | } |
| 77 | 247 | |
| 78 | export { CODEX_REASONING_EFFORT_OPTIONS }; | |
| 248 | export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS }; |
scripts/claude-fast-mode-probe.ts created+129| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | ||
| 3 | type ProbeMode = "off" | "on"; | |
| 4 | ||
| 5 | async function* emptyPrompt(): AsyncGenerator<never, void, void> {} | |
| 6 | ||
| 7 | function 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 | ||
| 55 | async 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 | ||
| 97 | function 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 | ||
| 115 | async 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 | ||
| 129 | await main(); |
scripts/claude-haiku-thinking-probe.ts created+261| ... | ... | @@ -0,0 +1,261 @@ |
| 1 | import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | ||
| 3 | type ProbeCase = | |
| 4 | | "default" | |
| 5 | | "effort-low" | |
| 6 | | "effort-high" | |
| 7 | | "settings-off" | |
| 8 | | "settings-on" | |
| 9 | | "thinking-disabled" | |
| 10 | | "thinking-enabled"; | |
| 11 | ||
| 12 | function 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 | ||
| 84 | function 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 | ||
| 137 | type 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 | ||
| 153 | async 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 | ||
| 212 | function 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 | ||
| 243 | async 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 | ||
| 261 | await main(); |
scripts/package.json+3| ... | ... | @@ -4,10 +4,13 @@ |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "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", | |
| 7 | 9 | "typecheck": "tsc --noEmit", |
| 8 | 10 | "test": "vitest run" |
| 9 | 11 | }, |
| 10 | 12 | "dependencies": { |
| 13 | "@anthropic-ai/claude-agent-sdk": "^0.2.77", | |
| 11 | 14 | "@effect/platform-node": "catalog:", |
| 12 | 15 | "@t3tools/contracts": "workspace:*", |
| 13 | 16 | "@t3tools/shared": "workspace:*", |