authorgravatar for jason.laster.11@gmail.comJason Laster <jason.laster.11@gmail.com> 2026-03-10 00:17:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 00:17:18-07:00
log223c3dc8005b583b19c502320f5de929958a7efd
treef2dfa92ecf3dcc712e6e43821e286003f7576b54
parentafb6e89bfdd9164c142162597bb865540b1909d2
signature Signed by PGP key B5690EEEBB952194

chore(ci): add and apply `fmt:check` script (#779)

* Add formatting conventions * Add a fmt lint check * rely on defaults --------- Co-authored-by: Julius Marminge <julius0216@outlook.com>

106 files changed, 2137 insertions(+), 1868 deletions(-)

.github/workflows/ci.yml+4-1
......@@ -8,7 +8,7 @@ on:
88
99jobs:
1010 quality:
11 name: Lint, Typecheck, Test, Browser Test, Build
11 name: Format, Lint, Typecheck, Test, Browser Test, Build
1212 runs-on: blacksmith-4vcpu-ubuntu-2404
1313 steps:
1414 - name: Checkout
......@@ -45,6 +45,9 @@ jobs:
4545 - name: Install dependencies
4646 run: bun install --frozen-lockfile
4747
48 - name: Format
49 run: bun run fmt:check
50
4851 - name: Lint
4952 run: bun run lint
5053
.oxfmtrc.json+3-2
......@@ -7,7 +7,8 @@
77 "node_modules",
88 "bun.lock",
99 "*.tsbuildinfo",
10 "**/routeTree.gen.ts"
10 "**/routeTree.gen.ts",
11 "apps/web/public/mockServiceWorker.js"
1112 ],
12 "experimentalSortPackageJson": {}
13 "sortPackageJson": {}
1314}
.plans/01-shared-model-normalization.md+7
......@@ -1,20 +1,24 @@
11# Plan: Centralize Model Normalization in Contracts
22
33## Summary
4
45Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth.
56
67## Motivation
8
79- Removes duplicated logic between:
810 - `apps/desktop/src/codexAppServerManager.ts`
911 - `apps/renderer/src/model-logic.ts`
1012- Prevents behavior drift when model aliases/defaults are updated.
1113
1214## Scope
15
1316- Add shared model utilities to contracts.
1417- Update desktop and renderer to consume shared utilities.
1518- Keep renderer-specific display options in renderer.
1619
1720## Proposed Changes
21
18221. Add `packages/contracts/src/model.ts` with:
1923 - Canonical model list
2024 - Alias map
......@@ -29,14 +33,17 @@ Move model alias/default normalization into `packages/contracts` so desktop and
2933 - Keep renderer tests focused on renderer-only behavior.
3034
3135## Risks
36
3237- Desktop/renderer may currently rely on slightly different fallback behavior.
3338- Import graph must avoid bundling issues for Electron main/preload.
3439
3540## Validation
41
3642- `bun run test`
3743- `bun run typecheck`
3844- Manual check that model selection and session start still send expected model slug.
3945
4046## Done Criteria
47
4148- No duplicated alias/default map in desktop and renderer.
4249- Shared model utilities are contract-tested.
.plans/02-typed-ipc-boundaries.md+7
......@@ -1,17 +1,21 @@
11# Plan: Strengthen Typed IPC Boundaries in Main Process
22
33## Summary
4
45Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers.
56
67## Motivation
8
79- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`.
810- Casts can hide contract breakages until runtime.
911
1012## Scope
13
1114- Desktop main process IPC registration.
1215- Optional shared helper for handler registration.
1316
1417## Proposed Changes
18
15191. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to:
1620 - Parse payload(s) with Zod schemas
1721 - Standardize typed handler signatures
......@@ -24,14 +28,17 @@ Replace loose payload casting in IPC handlers with strict schema parsing and typ
24284. Add tests for handler parsing failure paths (invalid payloads).
2529
2630## Risks
31
2732- Refactor can subtly change IPC error shape/messages.
2833- Helper abstraction should stay simple and not obscure control flow.
2934
3035## Validation
36
3137- `bun run test`
3238- `bun run typecheck`
3339- Manual invalid payload check from renderer/devtools to confirm fast failure.
3440
3541## Done Criteria
42
3643- No provider handler uses `payload as Parameters<...>`.
3744- All IPC entrypoints parse unknown payloads at boundary.
.plans/03-split-codex-app-server-manager.md+7
......@@ -1,9 +1,11 @@
11# Plan: Decompose CodexAppServerManager
22
33## Summary
4
45Split `CodexAppServerManager` into smaller modules with clear responsibilities.
56
67## Motivation
8
79- `apps/desktop/src/codexAppServerManager.ts` is large and mixes:
810 - Process lifecycle
911 - JSON-RPC parsing/routing
......@@ -12,10 +14,12 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities.
1214- This increases regression risk and slows changes.
1315
1416## Scope
17
1518- Desktop provider internals only.
1619- Keep external behavior/API stable.
1720
1821## Proposed Changes
22
19231. Extract modules:
2024 - `codex/processLifecycle.ts`
2125 - `codex/jsonrpcRouter.ts`
......@@ -29,13 +33,16 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities.
2933 - Session state transitions
3034
3135## Risks
36
3237- Reordering event handling can change behavior.
3338- Must preserve pending request timeout/cancellation semantics.
3439
3540## Validation
41
3642- Existing tests pass.
3743- Add module-level tests for parsing and transition logic.
3844
3945## Done Criteria
46
4047- Main manager file materially smaller and orchestration-focused.
4148- Core protocol/state logic covered by focused tests.
.plans/04-split-chatview-component.md+7
......@@ -1,9 +1,11 @@
11# Plan: Split ChatView into Smaller UI/Logic Units
22
33## Summary
4
45Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
56
67## Motivation
8
79- `apps/renderer/src/components/ChatView.tsx` is large and handles:
810 - Session orchestration
911 - Send/interrupt actions
......@@ -13,10 +15,12 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
1315- Hard to test and maintain as one component.
1416
1517## Scope
18
1619- Renderer component boundaries and hooks.
1720- Keep visual behavior unchanged.
1821
1922## Proposed Changes
23
20241. Create hook: `apps/renderer/src/hooks/useChatSession.ts`
2125 - `ensureSession`
2226 - `sendTurn`
......@@ -29,12 +33,15 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
29334. Add focused tests for hook behavior (error handling, session reuse).
3034
3135## Risks
36
3237- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send).
3338
3439## Validation
40
3541- `bun run test`
3642- Manual smoke: send, stream, interrupt, model switch.
3743
3844## Done Criteria
45
3946- `ChatView.tsx` significantly reduced and easier to scan.
4047- Session logic isolated from rendering.
.plans/05-zod-persisted-state-validation.md+7
......@@ -1,17 +1,21 @@
11# Plan: Move Renderer Persisted-State Validation to Zod
22
33## Summary
4
45Use explicit Zod schemas for localStorage state parsing and migration.
56
67## Motivation
8
79- `apps/renderer/src/store.ts` has large manual sanitize functions.
810- Manual type guards are verbose and easier to get wrong during schema evolution.
911
1012## Scope
13
1114- Renderer state hydration/persistence path.
1215- No backend/protocol changes.
1316
1417## Proposed Changes
18
15191. Add schema module: `apps/renderer/src/persistenceSchema.ts`
1620 - Persisted payload versions (`v1`, `v2`)
1721 - Thread/message/project schemas
......@@ -23,12 +27,15 @@ Use explicit Zod schemas for localStorage state parsing and migration.
2327 - Unknown thread/project references filtered
2428
2529## Risks
30
2631- Overly strict schemas could drop valid historical data unexpectedly.
2732
2833## Validation
34
2935- Unit tests for migration/hydration.
3036- Manual reload test with existing localStorage data.
3137
3238## Done Criteria
39
3340- Store hydration logic is schema-driven.
3441- Migration behavior is tested and documented.
.plans/06-provider-logstream-lifecycle.md+7
......@@ -1,17 +1,21 @@
11# Plan: Add Provider Log Stream Lifecycle Management
22
33## Summary
4
45Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely.
56
67## Motivation
8
79- `apps/desktop/src/providerManager.ts` opens a write stream in constructor.
810- Stream lifecycle is not explicit on shutdown.
911
1012## Scope
13
1114- Desktop provider logging behavior.
1215- App shutdown integration.
1316
1417## Proposed Changes
18
15191. Add explicit `dispose()` on `ProviderManager`:
1620 - Remove event listeners
1721 - End/close log stream
......@@ -20,12 +24,15 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and
20244. Optional: per-session log files under `.logs/providers/`.
2125
2226## Risks
27
2328- Improper close sequencing may lose final log lines.
2429
2530## Validation
31
2632- Manual run/quit cycle to ensure no open handle warnings.
2733- Confirm logs flush on quit and file descriptors are not leaked.
2834
2935## Done Criteria
36
3037- ProviderManager owns complete log stream lifecycle.
3138- Shutdown path explicitly disposes provider resources.
.plans/07-ci-quality-gates.md+7
......@@ -1,17 +1,21 @@
11# Plan: Add CI Workflow for Core Quality Gates
22
33## Summary
4
45Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs.
56
67## Motivation
8
79- Repository currently has no CI workflow files.
810- Quality checks are only local/manual.
911
1012## Scope
13
1114- `.github/workflows/ci.yml`
1215- Bun + Turbo setup in CI.
1316
1417## Proposed Changes
18
15191. Add `ci.yml` with jobs:
1620 - Setup Bun and Node environment
1721 - Install deps
......@@ -22,13 +26,16 @@ Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-tes
22263. Configure caching for Bun/Turbo as appropriate.
2327
2428## Risks
29
2530- Smoke test may be flaky in headless CI environments.
2631- CI runtime can grow if caching is misconfigured.
2732
2833## Validation
34
2935- Verify workflow runs on a branch PR.
3036- Ensure failures surface clearly by job name.
3137
3238## Done Criteria
39
3340- CI blocks regressions in lint/typecheck/test.
3441- Workflow docs added to README.
.plans/08-precommit-format-and-lint.md+7
......@@ -1,17 +1,21 @@
11# Plan: Add Pre-Commit Formatting/Lint Hooks
22
33## Summary
4
45Introduce pre-commit automation so formatting and basic lint checks happen before commits.
56
67## Motivation
8
79- Current lint failures include formatting-only issues.
810- Shift-left feedback reduces noisy CI failures and cleanup churn.
911
1012## Scope
13
1114- Root tooling config and package scripts.
1215- No runtime code changes.
1316
1417## Proposed Changes
18
15191. Add hook tooling (e.g. Husky + lint-staged or Lefthook).
16202. Configure staged-file tasks:
1721 - `biome format --write`
......@@ -20,13 +24,16 @@ Introduce pre-commit automation so formatting and basic lint checks happen befor
20244. Keep checks fast to avoid developer friction.
2125
2226## Risks
27
2328- Slow hooks can frustrate contributors and be bypassed.
2429- Need to ensure compatibility with Bun workspace setup.
2530
2631## Validation
32
2733- Create sample staged changes and verify hook behavior.
2834- Confirm formatting fixes are applied automatically.
2935
3036## Done Criteria
37
3138- Pre-commit hook installed and documented.
3239- Formatting-only lint failures drop significantly.
.plans/09-event-state-test-expansion.md+7
......@@ -1,17 +1,21 @@
11# Plan: Expand Event/State Transition Test Coverage
22
33## Summary
4
45Add focused tests for renderer event handling and session evolution logic.
56
67## Motivation
8
79- Core behavior is event-driven and stateful.
810- Existing renderer tests cover only a subset of timeline/model behavior.
911
1012## Scope
13
1114- `apps/renderer/src/session-logic.test.ts`
1215- Optional reducer tests for `apps/renderer/src/store.ts`.
1316
1417## Proposed Changes
18
15191. Add tests for `evolveSession`:
1620 - `thread/started`
1721 - `turn/started`
......@@ -24,12 +28,15 @@ Add focused tests for renderer event handling and session evolution logic.
24283. Add reducer integration tests for `APPLY_EVENT`.
2529
2630## Risks
31
2732- Tests may be brittle if event payload fixtures are too coupled to implementation details.
2833
2934## Validation
35
3036- `bun run test`
3137- Ensure new tests remain deterministic and fast.
3238
3339## Done Criteria
40
3441- High-risk event transitions are covered by unit tests.
3542- Regressions in stream assembly/session status are caught quickly.
.plans/10-unify-process-session-abstraction.md+7
......@@ -1,17 +1,21 @@
11# Plan: Unify Process and PTY Session Abstractions in ProcessManager
22
33## Summary
4
45Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes.
56
67## Motivation
8
79- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic.
810- New execution backends/providers will multiply complexity.
911
1012## Scope
13
1114- Desktop process execution internals.
1215- Preserve public `ProcessManager` API.
1316
1417## Proposed Changes
18
15191. Introduce internal interface (e.g. `RuntimeSession`):
1620 - `write(data)`
1721 - `kill()`
......@@ -24,12 +28,15 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-pr
24285. Add tests for both implementations.
2529
2630## Risks
31
2732- PTY behavior differs by platform; abstraction must not hide required differences.
2833
2934## Validation
35
3036- Existing `processManager.test.ts` passes.
3137- Add PTY-path tests where feasible.
3238
3339## Done Criteria
40
3441- Manager no longer branches per backend in `write/kill/killAll`.
3542- Session backends are independently testable.
.plans/11-effect.md+1-3
......@@ -1,5 +1,3 @@
1
2
31PR 1: Service contracts + error taxonomy
42Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs.
53Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each).
......@@ -39,4 +37,4 @@ Remove throw-based flow entirely from provider path.
3937PR 10: Cleanup + deprecation removal
4038Remove legacy class implementations/adapters once parity is proven.
4139Finalize layer composition and startup graph docs.
42Add architecture notes for service boundaries and error model.
\ No newline at end of file
40Add architecture notes for service boundaries and error model.
.plans/12-effect-new.md+9
......@@ -1,12 +1,14 @@
11# Effect Migration Plan (From Current State)
22
33Current status summary:
4
45- Service contracts, typed errors, and most checkpoint/persistence services exist.
56- `ProviderServiceLive` is already native orchestration (not a thin adapter).
67- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`.
78- Checkpoint flow now avoids snapshot re-sync and is write-time driven.
89
910## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer`
11
1012- Build one runtime layer graph for provider + checkpoint + persistence + orchestration.
1113- Resolve `ProviderService` from runtime in `wsServer`.
1214- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls.
......@@ -14,12 +16,14 @@ Current status summary:
1416- Keep WS method/push payloads identical.
1517
1618## PR 2: Runtime Composition + Startup Ownership
19
1720- Create/centralize `AppLive` composition for server startup.
1821- Ensure outer runtime provides Node/platform services once.
1922- Ensure migrations run at startup via scoped/layer startup path.
2023- Remove ad-hoc service initialization in request-time paths.
2124
2225## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants
26
2327- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`.
2428- Remove per-session lock/cwd map leaks.
2529- Keep strict invariant model:
......@@ -29,12 +33,14 @@ Current status summary:
2933- Add tests for lifecycle cleanup and invariant-failure surfaces.
3034
3135## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation)
36
3237- Keep `ProviderService` as the public event surface.
3338- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control.
3439- Keep API as `subscribeToEvents` unless we explicitly choose stream API later.
3540- Add tests for ordering and subscriber isolation under load.
3641
3742## PR 5: Codex Runtime Split (Scoped Effect Core)
43
3844- Extract `CodexAppServerManager` responsibilities into Effect-native layers:
3945 - scoped process lifecycle
4046 - RPC request/response + pending map via `Deferred`
......@@ -43,16 +49,19 @@ Current status summary:
4349- Preserve protocol behavior and timeout semantics.
4450
4551## PR 6: Codex Protocol Decode Hardening
52
4653- Replace ad-hoc unknown parsing with runtime schema decode.
4754- Map decode failures to typed tagged errors with `cause` retained.
4855- Add regression tests for malformed/partial protocol frames.
4956
5057## PR 7: Remove Legacy Provider Stack
58
5159- Remove `ProviderManager` + legacy checkpoint integration from runtime path.
5260- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling).
5361- Update tests to assert only Effect service path is used.
5462
5563## PR 8: Final Cleanup + Docs
64
5665- Update architecture docs with final layer graph and service boundaries.
5766- Document error model and recovery semantics.
5867- Trim dead compatibility code and stale plan references.
.plans/13-provider-service-integration-tests.md+19-6
......@@ -1,6 +1,7 @@
11# ProviderService Integration Test Plan
22
33Goal:
4
45- Validate end-to-end `ProviderService` behavior with real layers:
56 - `ProviderServiceLive`
67 - `CheckpointServiceLive`
......@@ -13,6 +14,7 @@ Goal:
1314## Test Harness
1415
1516Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`:
17
1618- Service contract: `ProviderAdapterShape<ProviderAdapterError>`.
1719- Internal state:
1820 - session registry (session + cwd + threadId)
......@@ -29,13 +31,15 @@ Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Lay
2931 - `readThread`, `rollbackThread`, `stopSession`, `stopAll`.
3032
3133Use real git-backed temporary workspaces in integration tests:
34
3235- initialize repo with baseline commit
3336- run provider turn in workspace
3437- assert checkpoint diffs against real git refs
3538
3639## Core Integration Specs
3740
381) `startSession` initializes checkpoint root exactly once
411. `startSession` initializes checkpoint root exactly once
42
3943- Arrange:
4044 - start provider session in git repo.
4145- Assert:
......@@ -43,7 +47,8 @@ Use real git-backed temporary workspaces in integration tests:
4347 - checkpoint ref exists in git.
4448 - second `startSession` for new session creates a new independent root.
4549
462) Turn without filesystem change
502. Turn without filesystem change
51
4752- Arrange:
4853 - emit normal turn events, no file mutation.
4954- Assert:
......@@ -54,7 +59,8 @@ Use real git-backed temporary workspaces in integration tests:
5459 - `listCheckpoints` returns root + turn 1.
5560 - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff.
5661
573) Turn with filesystem change
623. Turn with filesystem change
63
5864- Arrange:
5965 - mutate `README.md` during turn.
6066- Assert:
......@@ -62,7 +68,8 @@ Use real git-backed temporary workspaces in integration tests:
6268 - `getCheckpointDiff(0 -> 1)` contains file path and hunk.
6369 - persisted checkpoint metadata includes non-empty `checkpointRef`.
6470
654) Multi-turn sequencing and checkpoint monotonicity
714. Multi-turn sequencing and checkpoint monotonicity
72
6673- Arrange:
6774 - turn 1: no file change
6875 - turn 2: file change
......@@ -72,7 +79,8 @@ Use real git-backed temporary workspaces in integration tests:
7279 - latest checkpoint is marked current.
7380 - diffs for adjacent turns map to expected filesystem deltas.
7481
755) Revert to checkpoint
825. Revert to checkpoint
83
7684- Arrange:
7785 - execute 3 turns with at least one file-changing turn.
7886 - call `revertToCheckpoint(turnCount=1)`.
......@@ -82,7 +90,8 @@ Use real git-backed temporary workspaces in integration tests:
8290 - DB rows for turns >1 are removed.
8391 - later refs are deleted from git.
8492
856) Capture failure surface
936. Capture failure surface
94
8695- Arrange:
8796 - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails.
8897- Assert:
......@@ -92,6 +101,7 @@ Use real git-backed temporary workspaces in integration tests:
92101## WebSocket Coverage (Thin Integration)
93102
94103Add one ws server integration spec:
104
95105- Subscribe to `providers.event`.
96106- Run a deterministic provider turn through ws methods.
97107- Assert push stream includes:
......@@ -101,10 +111,13 @@ Add one ws server integration spec:
101111## Proposed PR Split
102112
103113PR A:
114
104115- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup).
105116
106117PR B:
118
107119- Core ProviderService integration specs (cases 1-4).
108120
109121PR C:
122
110123- Revert + failure-path specs (cases 5-6) + ws thin integration spec.
.plans/14-server-authoritative-event-sourcing-cleanup.md+12
......@@ -1,6 +1,7 @@
11# Server-Authoritative Event-Sourcing Cleanup Plan
22
33Goal:
4
45- Move to a cleaner service architecture with:
56 - durable, server-authoritative event sourcing
67 - strict command routing/validation
......@@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite
8586## Commit Series
8687
8788### Commit 1: Split public vs system orchestration command contracts
89
8890- Create separate schemas/types:
8991 - `ClientOrchestrationCommandSchema`
9092 - `SystemOrchestrationCommandSchema`
......@@ -100,6 +102,7 @@ CheckpointCatalog ------> SQLite
100102 - preserve internal dispatch functionality for system commands
101103
102104### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary
105
103106- Add dedicated router service to validate, authorize, and route commands.
104107- Move command-to-event mapping out of `orchestration/Layer.ts` into handlers.
105108- Add aggregate-level invariant checks before append (thread exists, project exists, etc.).
......@@ -113,6 +116,7 @@ CheckpointCatalog ------> SQLite
113116 - handler happy-path tests per command type
114117
115118### Commit 3: Harden event store for idempotency + optimistic append metadata
119
116120- Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null).
117121- Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`.
118122- Add optional aggregate version metadata for future optimistic concurrency.
......@@ -125,6 +129,7 @@ CheckpointCatalog ------> SQLite
125129 - concurrent append behavior stays ordered and deterministic
126130
127131### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer`
132
128133- Create `ProviderRuntimeIngestionService` that:
129134 - subscribes to `ProviderService.streamEvents`
130135 - translates runtime events into orchestration commands
......@@ -139,6 +144,7 @@ CheckpointCatalog ------> SQLite
139144 - ws integration confirms same external push behavior
140145
141146### Commit 5: Make session directory durable (`ProviderSessionRegistry`)
147
142148- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry.
143149- Keep in-memory cache optional, but source of truth must be persistent.
144150- Add startup reconciliation to prune dead sessions / keep known thread mapping.
......@@ -152,6 +158,7 @@ CheckpointCatalog ------> SQLite
152158 - stale session cleanup semantics
153159
154160### Commit 6: Re-key checkpoint metadata from session to thread identity
161
155162- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`.
156163- Keep `session_id` as nullable metadata only.
157164- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity.
......@@ -165,6 +172,7 @@ CheckpointCatalog ------> SQLite
165172 - revert/diff still work after session churn
166173
167174### Commit 7: Add durable projection persistence for orchestration read models
175
168176- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency.
169177- Keep event stream as source of truth; projection rebuild stays deterministic.
170178- `getSnapshot` reads from projection store (memory cache optional).
......@@ -177,6 +185,7 @@ CheckpointCatalog ------> SQLite
177185 - projection rebuild from events yields same result as previous reducer semantics
178186
179187### Commit 8: Narrow `ProviderService` responsibilities
188
180189- Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream.
181190- Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service.
182191- Preserve adapter pluggability and provider-neutral contracts.
......@@ -188,6 +197,7 @@ CheckpointCatalog ------> SQLite
188197 - checkpoint capture still triggered by turn completion through new coordinator
189198
190199### Commit 9: Look over schemas (contracts and events)
200
191201- Scan for unused schemas.
192202- Use effect/Schema everywhere
193203- Analyze which we need
......@@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite
196206 - Persistence entities
197207
198208### Commit 10: Remove dead legacy path and finalize docs
209
199210- Remove unused legacy manager/store path from active architecture:
200211 - `providerManager.ts`
201212 - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools)
......@@ -210,6 +221,7 @@ CheckpointCatalog ------> SQLite
210221 - no regressions in WS protocol behavior
211222
212223## Risk Controls
224
213225- Keep WS method names and payload contracts stable throughout.
214226- Gate each commit with targeted integration tests before moving forward.
215227- Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows.
.plans/15-effect-server.md+2-1
......@@ -1,10 +1,11 @@
11Rewrite `createServer` and `index.ts` to be Effect native.
22
33Maybe use `effect/unstable/Socket` for the web socket server
4
45- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts
56- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts
67
78- Migrate remaining runtime code to Effect
89 - `gitManager` -> `src/git`
910 - `terminalManager` -> `src/terminal` (Manager + PTY)
10 - ...
\ No newline at end of file
11 - ...
.plans/16-pr89-review-remediation-phases.md+20
......@@ -24,21 +24,25 @@
2424- Mark invalid/false-positive items with explicit rationale.
2525
2626Exit criteria:
27
2728- Every open thread is mapped to one canonical fix item or marked invalid.
2829
2930## Phase 1: Runtime Survival and Critical Event Wiring
3031
3132Related bug groups solved together:
33
3234- Worker loop/fiber fatal error handling in orchestration reactors.
3335- WebSocket message error boundaries and unhandled rejection guards.
3436- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected).
3537
3638Primary files:
39
3740- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
3841- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`
3942- `apps/server/src/wsServer.ts`
4043
4144Exit criteria:
45
4246- A single event-processing failure cannot permanently stop ingestion/reactor loops.
4347- WS message handling cannot produce unhandled promise rejections.
4448- Invalid provider-event-channel review findings are closed with architecture rationale.
......@@ -46,17 +50,20 @@ Exit criteria:
4650## Phase 2: State Consistency and Ordering
4751
4852Related bug groups solved together:
53
4954- Fire-and-forget revert completion causing consistency windows.
5055- Non-atomic append/projection paths and retry behavior.
5156- Race-sensitive thread/event association issues.
5257
5358Primary files:
59
5460- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`
5561- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`
5662- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
5763- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
5864
5965Exit criteria:
66
6067- Revert flow is deterministically reflected in read model updates.
6168- Append/project failure mode is explicit and safe under retry.
6269- No cross-thread misassociation under concurrent runtime events.
......@@ -64,12 +71,14 @@ Exit criteria:
6471## Phase 3: Checkpointing Correctness Bundle
6572
6673Related bug groups solved together:
74
6775- Checkpoint input normalization consistency.
6876- Snapshot/projector coverage mismatches.
6977- Checkpoint ref/workspace CWD utility duplication.
7078- Checkpoint diff/error handling behavior gaps.
7179
7280Primary files:
81
7382- `apps/server/src/checkpointing/Layers/CheckpointStore.ts`
7483- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts`
7584- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`
......@@ -77,6 +86,7 @@ Primary files:
7786- `apps/server/src/wsServer.ts`
7887
7988Exit criteria:
89
8090- Checkpoint capture/restore/revert paths use one normalization policy.
8191- Required projectors are actually represented in snapshot reads.
8292- Shared checkpoint/ref/CWD helpers are centralized.
......@@ -84,27 +94,32 @@ Exit criteria:
8494## Phase 4: Memory and Lifecycle Hygiene
8595
8696Related bug groups solved together:
97
8798- Unbounded in-memory dedup sets/maps.
8899- Missing cleanup/lifecycle protections in long-lived effects/resources.
89100
90101Primary files:
102
91103- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`
92104- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
93105- `apps/server/src/config.ts`
94106
95107Exit criteria:
108
96109- Long-running server memory does not grow unbounded from dedup bookkeeping.
97110- Resource cleanup paths are registered for interruption/shutdown.
98111
99112## Phase 5: Transport, Parsing, and Platform Edge Cases
100113
101114Related bug groups solved together:
115
102116- UTF-8 chunk boundary decode correctness.
103117- Markdown/file-link parsing edge cases.
104118- Shell/OS-specific PATH parsing behavior.
105119- Git rename parsing and small keybinding edge cases.
106120
107121Primary files:
122
108123- `apps/server/src/wsServer.ts`
109124- `apps/server/src/git/Layers/CodexTextGeneration.ts`
110125- `apps/web/src/markdown-links.ts`
......@@ -113,23 +128,27 @@ Primary files:
113128- `apps/server/src/keybindings.ts`
114129
115130Exit criteria:
131
116132- Edge-case parsers are robust across valid but non-trivial inputs.
117133- Platform-dependent command behavior has safe fallbacks.
118134
119135## Phase 6: Build and Maintainability Cleanup
120136
121137Related bug groups solved together:
138
122139- Build script/runtime assumption cleanup.
123140- Redundant error-union declarations and utility/type duplication.
124141- Non-functional cleanup comments/docs markers.
125142
126143Primary files:
144
127145- `apps/server/package.json`
128146- `apps/server/src/checkpointing/Errors.ts`
129147- Shared utility locations introduced during earlier phases
130148- `AGENTS.md` (if cleanup is still pending)
131149
132150Exit criteria:
151
133152- Build path is explicit and environment-safe.
134153- Redundant types/utilities are removed in favor of single sources of truth.
135154
......@@ -140,6 +159,7 @@ Exit criteria:
140159- Resolve threads with fix references per canonical checklist item.
141160
142161Exit criteria:
162
143163- Lint passes.
144164- Backend tests pass.
145165- All actionable review threads are resolved or explicitly justified.
.plans/16c-pr89-remediation-checklist.md+5-4
......@@ -5,6 +5,7 @@ _Last updated: 2026-02-26_
55This is the working checklist for remediation execution.
66
77Status values:
8
89- `TODO`: Not started
910- `IN_PROGRESS`: Currently being worked
1011- `BLOCKED`: Waiting on decision/dependency
......@@ -150,7 +151,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
150151 - Area: `WebSocket robustness`
151152 - File: `apps/web/src/wsTransport.ts:59`
152153 - Threads: PRRT_kwDORLtfbc5whtrN
153 - Audit note: Transport _tag override risk exists but current callsites are constrained.
154 - Audit note: Transport \_tag override risk exists but current callsites are constrained.
154155
155156### Phase 2
156157
......@@ -181,7 +182,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
181182 - Threads: PRRT_kwDORLtfbc5whxJO
182183 - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated.
183184
184- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
185- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes _after_ the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
185186 - Status: `DONE`
186187 - Verdict: `valid`
187188 - Severity: `Medium`
......@@ -198,7 +199,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
198199 - Severity: `Medium`
199200 - Area: `Checkpointing correctness`
200201 - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94`
201 - Threads: PRRT_kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv_, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s))
202 - Threads: PRRT*kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv*, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s))
202203 - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary.
203204
204205- [x] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
......@@ -250,7 +251,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
250251
251252### Phase 5
252253
253- [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after ` => ` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
254- [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after `=>` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
254255 - Status: `TODO`
255256 - Verdict: `valid`
256257 - Severity: `Medium`
.plans/spec-1-1-cutover-plan.md+51
......@@ -3,13 +3,16 @@
33Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft.
44
55Execution mode for this plan:
6
67- Hard cutover only. Existing DB and migration history are disposable.
78- Intermediate steps are allowed to break runtime, tests, typecheck, and lint.
89- We optimize for small, reviewable work units, not continuous app operability.
910- Only the final gate requires everything to run cleanly.
1011
1112## 1. Freeze SPEC contract as source of truth
13
1214Work units:
15
1316- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`.
1417- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants.
1518- Add app-level requirements per row: writer path, reader path, and owning module.
......@@ -17,17 +20,22 @@ Work units:
1720- Identify any ambiguous spec lines and record a concrete interpretation in the matrix.
1821
1922Deliverables:
23
2024- Complete matrix file with no unclassified rows.
2125- Single source checklist used by all later steps.
2226
2327Breakage allowed:
28
2429- No code changes required yet.
2530
2631Exit criteria:
32
2733- Every requirement in `7.1`-`7.4` has exactly one matrix row.
2834
2935## 2. Hard cutover migrations (replace current migration set)
36
3037Work units:
38
3139- Delete the current legacy migration files and rewrite migration loader ordering.
3240- Create `001_orchestration_events.ts` with full envelope columns and required event indexes.
3341- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes.
......@@ -47,17 +55,22 @@ Work units:
4755- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated.
4856
4957Deliverables:
58
5059- New 5-file migration chain.
5160- Updated migration loader references only new migrations.
5261
5362Breakage allowed:
63
5464- Repositories/services can be temporarily broken due to removed old tables.
5565
5666Exit criteria:
67
5768- Fresh DB initializes with only canonical tables plus migration bookkeeping.
5869
5970## 3. Align persistence row/request schemas to DB 1:1
71
6072Work units:
73
6174- Define row schemas for each canonical table (contracts or persistence layer module).
6275- Define request schemas for every insert/update/query operation touching canonical tables.
6376- Remove or deprecate row/request schemas tied to deleted legacy tables.
......@@ -65,17 +78,22 @@ Work units:
6578- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms).
6679
6780Deliverables:
81
6882- Canonical row/request schemas committed.
6983- Zero references to legacy row schemas in active code paths.
7084
7185Breakage allowed:
86
7287- Runtime can still fail while query layers are being rewired.
7388
7489Exit criteria:
90
7591- Every canonical table used in code has a typed row schema and typed request schema.
7692
7793## 4. Rewrite event store for full persisted envelope
94
7895Work units:
96
7997- Refactor append path to write full envelope fields:
8098 - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json`
8199- Implement stream version assignment/checking per aggregate stream.
......@@ -84,16 +102,21 @@ Work units:
84102- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`).
85103
86104Deliverables:
105
87106- Event store append/replay fully aligned with canonical envelope.
88107
89108Breakage allowed:
109
90110- Command dispatch flow can be partially broken until receipts/projectors are updated.
91111
92112Exit criteria:
113
93114- Event store no longer depends on legacy event table shape.
94115
95116## 5. Add command receipt idempotency
117
96118Work units:
119
97120- Introduce persistence access layer for `orchestration_command_receipts`.
98121- In command dispatch flow, check existing receipt by `commandId` before append.
99122- On first execution, persist accepted receipt with `resultSequence`.
......@@ -102,16 +125,21 @@ Work units:
102125- Ensure receipt write and event append ordering is deterministic.
103126
104127Deliverables:
128
105129- Dispatch path with idempotency behavior wired through receipts.
106130
107131Breakage allowed:
132
108133- Snapshot/read model may still be inconsistent until projectors are fully wired.
109134
110135Exit criteria:
136
111137- Duplicate command IDs no longer create duplicate events.
112138
113139## 6. Build DB-backed projection pipeline
140
114141Work units:
142
115143- Create projector runner that consumes events and applies table-specific projections.
116144- Implement projector handlers for each projection table.
117145- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction.
......@@ -120,16 +148,21 @@ Work units:
120148- Add safe resume logic from projector `last_applied_sequence`.
121149
122150Deliverables:
151
123152- Persistent projector pipeline writing all `projection_*` tables.
124153
125154Breakage allowed:
155
126156- Web/API layer may still read old in-memory model until step 7.
127157
128158Exit criteria:
159
129160- Events drive projection rows in DB; projection state advances transactionally.
130161
131162## 7. Move RPC reads to projections and diff blobs
163
132164Work units:
165
133166- Implement snapshot query service reading only projection tables.
134167- Build thread hydration from projection rows: messages, activities, checkpoints, session.
135168- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`.
......@@ -138,16 +171,21 @@ Work units:
138171- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`.
139172
140173Deliverables:
174
141175- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store.
142176
143177Breakage allowed:
178
144179- Provider runtime persistence may still be partially legacy until step 8.
145180
146181Exit criteria:
182
147183- No orchestration read RPC depends on legacy tables or in-memory-only state.
148184
149185## 8. Migrate provider runtime persistence to canonical table
186
150187Work units:
188
151189- Create repository/service for `provider_session_runtime`.
152190- Update adapter/session manager to persist runtime/resume cursor in new table.
153191- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`.
......@@ -155,16 +193,21 @@ Work units:
155193- Verify restart/resume path reads runtime state from canonical table only.
156194
157195Deliverables:
196
158197- Provider runtime state entirely backed by `provider_session_runtime`.
159198
160199Breakage allowed:
200
161201- Some legacy interfaces may still exist but should be disconnected.
162202
163203Exit criteria:
204
164205- Runtime restore no longer reads/writes legacy provider session persistence.
165206
166207## 9. Remove old cruft aggressively
208
167209Work units:
210
168211- Delete legacy repositories/services that map to removed tables.
169212- Remove dead migration imports and obsolete persistence service interfaces.
170213- Remove compatibility code paths that translate legacy row shapes.
......@@ -172,16 +215,21 @@ Work units:
172215- Update internal docs/comments to reference canonical projection/event model only.
173216
174217Deliverables:
218
175219- Legacy persistence and translation layers removed from active codebase.
176220
177221Breakage allowed:
222
178223- Temporary compile failures acceptable while deletion/refactor is in progress.
179224
180225Exit criteria:
226
181227- No production code path references deleted legacy tables/services.
182228
183229## 10. Final verification gate (first point where green is required)
230
184231Work units:
232
185233- Add migration tests that assert canonical tables, columns, constraints, and indexes.
186234- Add event store tests for envelope persistence, metadata, actor kind, and replay.
187235- Add receipt idempotency tests for accept/reject/duplicate paths.
......@@ -192,10 +240,13 @@ Work units:
192240- Run project lint/typecheck/tests and fix failures.
193241
194242Deliverables:
243
195244- Green checks with canonical schema + persistence model in place.
196245
197246Breakage allowed:
247
198248- None at end of step.
199249
200250Exit criteria:
251
201252- SPEC `7.1`-`7.4` requirements satisfied and validated by tests.
.plans/spec-contract-matrix.md+32
......@@ -1,6 +1,7 @@
11# SPEC Contract Matrix (Sections 7.1-7.4)
22
33Status legend:
4
45- `required`: requirement acknowledged, no current implementation claim yet.
56- `implemented`: requirement currently satisfied in code + schema.
67- `to-replace`: partial/misaligned implementation exists and must be replaced.
......@@ -9,6 +10,7 @@ Status legend:
910## 7.1 Write-Side Persisted Tables
1011
1112### W1
13
1214- Spec ref: `7.1.1 orchestration_events`
1315- Requirement: append-only event store with canonical envelope columns.
1416- SQL contract:
......@@ -32,6 +34,7 @@ Status legend:
3234- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`.
3335
3436### W2
37
3538- Spec ref: `7.1.2 orchestration_command_receipts`
3639- Requirement: command idempotency + ack replay receipts table.
3740- SQL contract:
......@@ -49,6 +52,7 @@ Status legend:
4952- Notes: missing table and missing idempotency flow.
5053
5154### W3
55
5256- Spec ref: `7.1.3 checkpoint_diff_blobs`
5357- Requirement: store large plaintext diffs separate from checkpoint summaries.
5458- SQL contract:
......@@ -65,6 +69,7 @@ Status legend:
6569- Notes: no canonical diff blob table yet.
6670
6771### W4
72
6873- Spec ref: `7.1.4 provider_session_runtime`
6974- Requirement: server-internal provider runtime/resume state.
7075- SQL contract:
......@@ -86,6 +91,7 @@ Status legend:
8691## 7.2 Canonical Persisted Event Schema
8792
8893### E1
94
8995- Spec ref: `7.2 OrchestrationPersistedEventSchema`
9096- Requirement: full typed persisted event envelope in shared contracts.
9197- SQL contract: envelope fields in W1 must map 1:1 to contracts schema.
......@@ -96,6 +102,7 @@ Status legend:
96102- Notes: contract schema exists; DB + store mapping still incomplete.
97103
98104### E2
105
99106- Spec ref: `7.2 Rules/payload discriminated by eventType`
100107- Requirement: `payload` validation keyed by `eventType`.
101108- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected.
......@@ -106,6 +113,7 @@ Status legend:
106113- Notes: decode is present but DB does not persist full envelope columns.
107114
108115### E3
116
109117- Spec ref: `7.2 Rules/provider ids scope`
110118- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement.
111119- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id.
......@@ -116,6 +124,7 @@ Status legend:
116124- Notes: metadata plumbing is incomplete in persistence path.
117125
118126### E4
127
119128- Spec ref: `7.2 Rules/streamVersion concurrency guard`
120129- Requirement: stream version monotonic per aggregate stream; enforced on write.
121130- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream.
......@@ -128,6 +137,7 @@ Status legend:
128137## 7.3 Required Projected Tables (Read Models)
129138
130139### P1
140
131141- Spec ref: `7.3.1 projection_projects`
132142- Requirement: persisted project projection table.
133143- SQL contract:
......@@ -145,6 +155,7 @@ Status legend:
145155- Notes: legacy `projects` table is separate concept and should be removed from orchestration model.
146156
147157### P2
158
148159- Spec ref: `7.3.2 projection_threads`
149160- Requirement: persisted thread projection table.
150161- SQL contract:
......@@ -165,6 +176,7 @@ Status legend:
165176- Notes: missing table and projector writes.
166177
167178### P3
179
168180- Spec ref: `7.3.3 projection_thread_messages`
169181- Requirement: persisted thread message projection table.
170182- SQL contract:
......@@ -183,6 +195,7 @@ Status legend:
183195- Notes: missing table and message projection writes.
184196
185197### P4
198
186199- Spec ref: `7.3.4 projection_thread_activities`
187200- Requirement: persisted thread activity projection table.
188201- SQL contract:
......@@ -201,6 +214,7 @@ Status legend:
201214- Notes: no canonical activity projection persistence.
202215
203216### P5
217
204218- Spec ref: `7.3.5 projection_thread_sessions`
205219- Requirement: persisted thread session projection table.
206220- SQL contract:
......@@ -219,6 +233,7 @@ Status legend:
219233- Notes: current provider session table is not this domain projection.
220234
221235### P6
236
222237- Spec ref: `7.3.6 projection_thread_turns`
223238- Requirement: persisted thread turn projection table.
224239- SQL contract:
......@@ -237,6 +252,7 @@ Status legend:
237252- Notes: missing table and projection logic.
238253
239254### P7
255
240256- Spec ref: `7.3.7 projection_checkpoints`
241257- Requirement: persisted checkpoint summary projection table.
242258- SQL contract:
......@@ -256,6 +272,7 @@ Status legend:
256272- Notes: current table semantics do not match canonical checkpoint projection schema.
257273
258274### P8
275
259276- Spec ref: `7.3.8 projection_pending_approvals`
260277- Requirement: persisted pending-approval projection table.
261278- SQL contract:
......@@ -273,6 +290,7 @@ Status legend:
273290- Notes: missing table and projection logic.
274291
275292### P9
293
276294- Spec ref: `7.3.9 projection_state`
277295- Requirement: projector progress tracking table.
278296- SQL contract:
......@@ -286,6 +304,7 @@ Status legend:
286304- Notes: missing table and projector bookkeeping.
287305
288306### P10
307
289308- Spec ref: `7.3 Projection consistency rules`
290309- Requirement: projector row updates and `projection_state` update must be atomic per event.
291310- SQL contract: per-projector transaction boundary covering both projection write and state update.
......@@ -296,6 +315,7 @@ Status legend:
296315- Notes: requires transactional projection executor.
297316
298317### P11
318
299319- Spec ref: `7.3 Optional debug field`
300320- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness.
301321- SQL contract: optional; not required in baseline schema.
......@@ -308,6 +328,7 @@ Status legend:
308328## 7.4 Snapshot and RPC Requirements
309329
310330### R1
331
311332- Spec ref: `7.4.1`
312333- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`.
313334- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`.
......@@ -318,6 +339,7 @@ Status legend:
318339- Notes: current in-memory read model path must be removed for SPEC compliance.
319340
320341### R2
342
321343- Spec ref: `7.4.2`
322344- Requirement: snapshot `projects[]` source is `projection_projects`.
323345- SQL contract: `projects` collection assembled from `projection_projects` rows.
......@@ -328,6 +350,7 @@ Status legend:
328350- Notes: no DB project projection reader exists yet.
329351
330352### R3
353
331354- Spec ref: `7.4.3`
332355- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields.
333356- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`.
......@@ -338,6 +361,7 @@ Status legend:
338361- Notes: canonical projection table and reader not implemented.
339362
340363### R4
364
341365- Spec ref: `7.4.4`
342366- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs.
343367- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only.
......@@ -348,6 +372,7 @@ Status legend:
348372- Notes: current checkpoint repository is not canonical source.
349373
350374### R5
375
351376- Spec ref: `7.4.5`
352377- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`.
353378- SQL contract: session lookup by `thread_id` from projection table.
......@@ -358,6 +383,7 @@ Status legend:
358383- Notes: remove provider-session-as-routing-key behavior.
359384
360385### R6
386
361387- Spec ref: `7.4.6`
362388- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors.
363389- SQL contract: `MIN(last_applied_sequence)` across required projector keys.
......@@ -368,6 +394,7 @@ Status legend:
368394- Notes: must move from in-memory sequence to DB projection-state semantics.
369395
370396### R7
397
371398- Spec ref: `7.4.7`
372399- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence).
373400- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start.
......@@ -380,22 +407,27 @@ Status legend:
380407## Ambiguous/Interpretation Decisions (tracked upfront)
381408
382409### A1
410
383411- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming.
384412- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code.
385413
386414### A2
415
387416- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload.
388417- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries.
389418
390419### A3
420
391421- Topic: `snapshotSequence` dependency set for min-sequence computation.
392422- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`).
393423
394424### A4
425
395426- Topic: no-gap handoff mechanism in `7.4.7`.
396427- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`.
397428
398429## Checklist Completeness Statement
430
399431- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`.
400432- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`.
401433- Unclassified rows: `0`.
apps/desktop/scripts/dev-electron.mjs+15-7
......@@ -7,7 +7,11 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
88const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
99const devServerUrl = `http://localhost:${port}`;
10const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"];
10const requiredFiles = [
11 "dist-electron/main.js",
12 "dist-electron/preload.js",
13 "../server/dist/index.mjs",
14];
1115const watchedDirectories = [
1216 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
1317 { directory: "../server/dist", files: new Set(["index.mjs"]) },
......@@ -148,13 +152,17 @@ function scheduleRestart() {
148152
149153function startWatchers() {
150154 for (const { directory, files } of watchedDirectories) {
151 const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => {
152 if (typeof filename !== "string" || !files.has(filename)) {
153 return;
154 }
155 const watcher = watch(
156 join(desktopDir, directory),
157 { persistent: true },
158 (_eventType, filename) => {
159 if (typeof filename !== "string" || !files.has(filename)) {
160 return;
161 }
155162
156 scheduleRestart();
157 });
163 scheduleRestart();
164 },
165 );
158166
159167 watchers.push(watcher);
160168 }
apps/desktop/src/main.ts+12-9
......@@ -15,10 +15,7 @@ import { NetService } from "@t3tools/shared/Net";
1515import { RotatingFileSink } from "@t3tools/shared/logging";
1616import { showDesktopConfirmDialog } from "./confirmDialog";
1717import { fixPath } from "./fixPath";
18import {
19 getAutoUpdateDisabledReason,
20 shouldBroadcastDownloadProgress,
21} from "./updateState";
18import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
2219import {
2320 createInitialDesktopUpdateState,
2421 reduceDesktopUpdateStateOnCheckFailure,
......@@ -702,7 +699,9 @@ async function checkForUpdates(reason: string): Promise<void> {
702699 await autoUpdater.checkForUpdates();
703700 } catch (error: unknown) {
704701 const message = error instanceof Error ? error.message : String(error);
705 setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()));
702 setUpdateState(
703 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
704 );
706705 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
707706 } finally {
708707 updateCheckInFlight = false;
......@@ -764,9 +763,7 @@ function configureAutoUpdater(): void {
764763 updaterConfigured = true;
765764
766765 const githubToken =
767 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() ||
768 process.env.GH_TOKEN?.trim() ||
769 "";
766 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
770767 if (githubToken) {
771768 // When a token is provided, re-configure the feed with `private: true` so
772769 // electron-updater uses the GitHub API (api.github.com) instead of the
......@@ -801,7 +798,13 @@ function configureAutoUpdater(): void {
801798 console.info("[desktop-updater] Looking for updates...");
802799 });
803800 autoUpdater.on("update-available", (info) => {
804 setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString()));
801 setUpdateState(
802 reduceDesktopUpdateStateOnUpdateAvailable(
803 updateState,
804 info.version,
805 new Date().toISOString(),
806 ),
807 );
805808 lastLoggedDownloadMilestone = -1;
806809 console.info(`[desktop-updater] Update available: ${info.version}`);
807810 });
apps/desktop/src/runtimeArch.ts+1-2
......@@ -25,8 +25,7 @@ export function resolveDesktopRuntimeInfo(
2525 };
2626 }
2727
28 const hostArch =
29 appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
28 const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
3029
3130 return {
3231 hostArch,
apps/desktop/src/updateMachine.test.ts+4-1
......@@ -83,7 +83,10 @@ describe("updateMachine", () => {
8383 },
8484 "1.1.0",
8585 );
86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out");
86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(
87 downloaded,
88 "backend shutdown timed out",
89 );
8790
8891 expect(downloaded.status).toBe("downloaded");
8992 expect(downloaded.downloadedVersion).toBe("1.1.0");
apps/server/integration/TestProviderAdapter.integration.ts+301-302
......@@ -72,10 +72,9 @@ function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupt
7272 return "completed";
7373}
7474
75function mapRequestType(requestKind: unknown):
76 | "command_execution_approval"
77 | "file_change_approval"
78 | "unknown" {
75function mapRequestType(
76 requestKind: unknown,
77): "command_execution_approval" | "file_change_approval" | "unknown" {
7978 if (requestKind === "command") {
8079 return "command_execution_approval";
8180 }
......@@ -239,323 +238,323 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
239238 }>
240239 >();
241240
242 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
241 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
243242
244 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
245 Effect.gen(function* () {
246 if (input.provider !== undefined && input.provider !== provider) {
247 return yield* new ProviderAdapterValidationError({
248 provider,
249 operation: "startSession",
250 issue: `Expected provider '${provider}' but received '${input.provider}'.`,
251 });
252 }
243 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
244 Effect.gen(function* () {
245 if (input.provider !== undefined && input.provider !== provider) {
246 return yield* new ProviderAdapterValidationError({
247 provider,
248 operation: "startSession",
249 issue: `Expected provider '${provider}' but received '${input.provider}'.`,
250 });
251 }
253252
254 sessionCount += 1;
255 const threadId = input.threadId;
256 const createdAt = nowIso();
257
258 const session: ProviderSession = {
259 provider,
260 status: "ready",
261 runtimeMode: input.runtimeMode,
262 threadId,
263 cwd: input.cwd,
264 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
265 createdAt,
266 updatedAt: createdAt,
267 };
268
269 sessions.set(threadId, {
270 session,
271 snapshot: {
253 sessionCount += 1;
254 const threadId = input.threadId;
255 const createdAt = nowIso();
256
257 const session: ProviderSession = {
258 provider,
259 status: "ready",
260 runtimeMode: input.runtimeMode,
272261 threadId,
273 turns: [],
274 },
275 turnCount: 0,
276 queuedResponses: queuedResponsesForNextSession.splice(0),
277 rollbackCalls: [],
278 });
262 cwd: input.cwd,
263 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
264 createdAt,
265 updatedAt: createdAt,
266 };
279267
280 return session;
281 });
268 sessions.set(threadId, {
269 session,
270 snapshot: {
271 threadId,
272 turns: [],
273 },
274 turnCount: 0,
275 queuedResponses: queuedResponsesForNextSession.splice(0),
276 rollbackCalls: [],
277 });
282278
283 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>
284 Effect.gen(function* () {
285 const state = sessions.get(input.threadId);
286 if (!state) {
287 return yield* missingSessionEffect(provider, input.threadId);
288 }
279 return session;
280 });
289281
290 state.turnCount += 1;
291 const turnCount = state.turnCount;
292 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
282 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>
283 Effect.gen(function* () {
284 const state = sessions.get(input.threadId);
285 if (!state) {
286 return yield* missingSessionEffect(provider, input.threadId);
287 }
293288
294 const response = state.queuedResponses.shift();
295 if (!response) {
296 return yield* new ProviderAdapterValidationError({
297 provider,
298 operation: "sendTurn",
299 issue: `No queued turn response for thread ${input.threadId}.`,
300 });
301 }
289 state.turnCount += 1;
290 const turnCount = state.turnCount;
291 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
302292
303 const assistantDeltas: string[] = [];
304 const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = [];
305 for (const fixtureEvent of response.events) {
306 const rawEvent: Record<string, unknown> = {
307 ...(fixtureEvent as Record<string, unknown>),
308 eventId: randomUUID(),
309 provider,
310 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
311 createdAt: nowIso(),
312 };
313 rawEvent.threadId = state.snapshot.threadId;
314 if (Object.hasOwn(rawEvent, "turnId")) {
315 rawEvent.turnId = turnId;
293 const response = state.queuedResponses.shift();
294 if (!response) {
295 return yield* new ProviderAdapterValidationError({
296 provider,
297 operation: "sendTurn",
298 issue: `No queued turn response for thread ${input.threadId}.`,
299 });
316300 }
317301
318 const runtimeEvent = normalizeFixtureEvent(rawEvent);
319 const runtimeType = (runtimeEvent as { type: string }).type;
320 if (runtimeType === "content.delta") {
321 const payload = runtimeEvent.payload as { delta?: unknown } | undefined;
322 if (typeof payload?.delta === "string") {
323 assistantDeltas.push(payload.delta);
302 const assistantDeltas: string[] = [];
303 const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = [];
304 for (const fixtureEvent of response.events) {
305 const rawEvent: Record<string, unknown> = {
306 ...(fixtureEvent as Record<string, unknown>),
307 eventId: randomUUID(),
308 provider,
309 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
310 createdAt: nowIso(),
311 };
312 rawEvent.threadId = state.snapshot.threadId;
313 if (Object.hasOwn(rawEvent, "turnId")) {
314 rawEvent.turnId = turnId;
315 }
316
317 const runtimeEvent = normalizeFixtureEvent(rawEvent);
318 const runtimeType = (runtimeEvent as { type: string }).type;
319 if (runtimeType === "content.delta") {
320 const payload = runtimeEvent.payload as { delta?: unknown } | undefined;
321 if (typeof payload?.delta === "string") {
322 assistantDeltas.push(payload.delta);
323 }
324 } else if (runtimeType === "message.delta") {
325 const legacyDelta = (runtimeEvent as { delta?: unknown }).delta;
326 if (typeof legacyDelta === "string") {
327 assistantDeltas.push(legacyDelta);
328 }
324329 }
325 } else if (runtimeType === "message.delta") {
326 const legacyDelta = (runtimeEvent as { delta?: unknown }).delta;
327 if (typeof legacyDelta === "string") {
328 assistantDeltas.push(legacyDelta);
330 if (runtimeEvent.type === "turn.completed") {
331 deferredTurnCompletedEvents.push(runtimeEvent);
332 continue;
329333 }
334
335 yield* emit(runtimeEvent);
330336 }
331 if (runtimeEvent.type === "turn.completed") {
332 deferredTurnCompletedEvents.push(runtimeEvent);
333 continue;
337
338 if (response.mutateWorkspace && state.session.cwd) {
339 yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount });
334340 }
335341
336 yield* emit(runtimeEvent);
337 }
342 const userItem = {
343 type: "userMessage",
344 content: [{ type: "text", text: input.input }],
345 } as const;
346 const assistantText = assistantDeltas.join("");
347 const nextItems: Array<unknown> =
348 assistantText.length > 0
349 ? [userItem, { type: "agentMessage", text: assistantText }]
350 : [userItem];
351
352 const nextTurn: ProviderThreadTurnSnapshot = {
353 id: turnId,
354 items: nextItems,
355 };
338356
339 if (response.mutateWorkspace && state.session.cwd) {
340 yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount });
341 }
357 state.snapshot = {
358 threadId: state.snapshot.threadId,
359 turns: [...state.snapshot.turns, nextTurn],
360 };
342361
343 const userItem = {
344 type: "userMessage",
345 content: [{ type: "text", text: input.input }],
346 } as const;
347 const assistantText = assistantDeltas.join("");
348 const nextItems: Array<unknown> =
349 assistantText.length > 0
350 ? [userItem, { type: "agentMessage", text: assistantText }]
351 : [userItem];
352
353 const nextTurn: ProviderThreadTurnSnapshot = {
354 id: turnId,
355 items: nextItems,
356 };
357
358 state.snapshot = {
359 threadId: state.snapshot.threadId,
360 turns: [...state.snapshot.turns, nextTurn],
361 };
362
363 if (deferredTurnCompletedEvents.length === 0) {
364 yield* emit({
365 type: "turn.completed",
366 eventId: EventId.makeUnsafe(randomUUID()),
367 provider,
368 createdAt: nowIso(),
362 if (deferredTurnCompletedEvents.length === 0) {
363 yield* emit({
364 type: "turn.completed",
365 eventId: EventId.makeUnsafe(randomUUID()),
366 provider,
367 createdAt: nowIso(),
368 threadId: state.snapshot.threadId,
369 turnId,
370 payload: {
371 state: "completed",
372 },
373 });
374 } else {
375 for (const completedEvent of deferredTurnCompletedEvents) {
376 yield* emit(completedEvent);
377 }
378 }
379
380 return {
369381 threadId: state.snapshot.threadId,
370382 turnId,
371 payload: {
372 state: "completed",
373 },
374 });
375 } else {
376 for (const completedEvent of deferredTurnCompletedEvents) {
377 yield* emit(completedEvent);
378 }
383 } satisfies ProviderTurnStartResult;
384 });
385
386 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
387 threadId,
388 turnId,
389 ) =>
390 sessions.has(threadId)
391 ? Effect.sync(() => {
392 const existing = interruptCallsBySession.get(threadId) ?? [];
393 existing.push(turnId);
394 interruptCallsBySession.set(threadId, existing);
395 })
396 : missingSessionEffect(provider, threadId);
397
398 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
399 threadId,
400 requestId,
401 decision,
402 ) =>
403 sessions.has(threadId)
404 ? Effect.sync(() => {
405 const existing = approvalResponsesBySession.get(threadId) ?? [];
406 existing.push({
407 threadId,
408 requestId,
409 decision,
410 });
411 approvalResponsesBySession.set(threadId, existing);
412 })
413 : missingSessionEffect(provider, threadId);
414
415 const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = (
416 threadId,
417 _requestId,
418 _answers,
419 ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId));
420
421 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
422 Effect.sync(() => {
423 sessions.delete(threadId);
424 });
425
426 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>
427 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
428
429 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
430 Effect.succeed(sessions.has(threadId));
431
432 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
433 const state = sessions.get(threadId);
434 if (!state) {
435 return missingSessionEffect(provider, threadId);
436 }
437 return Effect.succeed(state.snapshot);
438 };
439
440 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
441 threadId,
442 numTurns,
443 ) => {
444 const state = sessions.get(threadId);
445 if (!state) {
446 return missingSessionEffect(provider, threadId);
447 }
448 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
449 return Effect.fail(
450 new ProviderAdapterValidationError({
451 provider,
452 operation: "rollbackThread",
453 issue: "numTurns must be an integer between 0 and current turn count.",
454 }),
455 );
379456 }
380457
381 return {
382 threadId: state.snapshot.threadId,
383 turnId,
384 } satisfies ProviderTurnStartResult;
385 });
386
387 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
388 threadId,
389 turnId,
390 ) =>
391 sessions.has(threadId)
392 ? Effect.sync(() => {
393 const existing = interruptCallsBySession.get(threadId) ?? [];
394 existing.push(turnId);
395 interruptCallsBySession.set(threadId, existing);
396 })
397 : missingSessionEffect(provider, threadId);
398
399 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
400 threadId,
401 requestId,
402 decision,
403 ) =>
404 sessions.has(threadId)
405 ? Effect.sync(() => {
406 const existing = approvalResponsesBySession.get(threadId) ?? [];
407 existing.push({
408 threadId,
409 requestId,
410 decision,
411 });
412 approvalResponsesBySession.set(threadId, existing);
413 })
414 : missingSessionEffect(provider, threadId);
415
416 const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = (
417 threadId,
418 _requestId,
419 _answers,
420 ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId));
421
422 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
423 Effect.sync(() => {
424 sessions.delete(threadId);
425 });
426
427 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>
428 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
429
430 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
431 Effect.succeed(sessions.has(threadId));
432
433 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
434 const state = sessions.get(threadId);
435 if (!state) {
436 return missingSessionEffect(provider, threadId);
437 }
438 return Effect.succeed(state.snapshot);
439 };
440
441 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
442 threadId,
443 numTurns,
444 ) => {
445 const state = sessions.get(threadId);
446 if (!state) {
447 return missingSessionEffect(provider, threadId);
448 }
449 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
450 return Effect.fail(
451 new ProviderAdapterValidationError({
452 provider,
453 operation: "rollbackThread",
454 issue: "numTurns must be an integer between 0 and current turn count.",
455 }),
458 return Effect.sync(() => {
459 state.rollbackCalls.push(numTurns);
460 state.snapshot = {
461 threadId: state.snapshot.threadId,
462 turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns),
463 };
464 state.turnCount = state.snapshot.turns.length;
465 return state.snapshot;
466 });
467 };
468
469 const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () =>
470 Effect.sync(() => {
471 sessions.clear();
472 });
473
474 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
475 provider,
476 capabilities: {
477 sessionModelSwitch: "in-session",
478 },
479 startSession,
480 sendTurn,
481 interruptTurn,
482 respondToRequest,
483 respondToUserInput,
484 stopSession,
485 listSessions,
486 hasSession,
487 readThread,
488 rollbackThread,
489 stopAll,
490 streamEvents: Stream.fromQueue(runtimeEvents),
491 };
492
493 const queueTurnResponse = (
494 threadId: ThreadId,
495 response: TestTurnResponse,
496 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>
497 Effect.sync(() => sessions.get(threadId)).pipe(
498 Effect.flatMap((state) =>
499 state
500 ? Effect.sync(() => {
501 state.queuedResponses.push(response);
502 })
503 : Effect.fail(sessionNotFound(provider, threadId)),
504 ),
456505 );
457 }
458
459 return Effect.sync(() => {
460 state.rollbackCalls.push(numTurns);
461 state.snapshot = {
462 threadId: state.snapshot.threadId,
463 turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns),
464 };
465 state.turnCount = state.snapshot.turns.length;
466 return state.snapshot;
467 });
468 };
469
470 const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () =>
471 Effect.sync(() => {
472 sessions.clear();
473 });
474
475 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
476 provider,
477 capabilities: {
478 sessionModelSwitch: "in-session",
479 },
480 startSession,
481 sendTurn,
482 interruptTurn,
483 respondToRequest,
484 respondToUserInput,
485 stopSession,
486 listSessions,
487 hasSession,
488 readThread,
489 rollbackThread,
490 stopAll,
491 streamEvents: Stream.fromQueue(runtimeEvents),
492 };
493
494 const queueTurnResponse = (
495 threadId: ThreadId,
496 response: TestTurnResponse,
497 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>
498 Effect.sync(() => sessions.get(threadId)).pipe(
499 Effect.flatMap((state) =>
500 state
501 ? Effect.sync(() => {
502 state.queuedResponses.push(response);
503 })
504 : Effect.fail(sessionNotFound(provider, threadId)),
505 ),
506 );
507
508 const queueTurnResponseForNextSession = (
509 response: TestTurnResponse,
510 ): Effect.Effect<void, never> =>
511 Effect.sync(() => {
512 queuedResponsesForNextSession.push(response);
513 });
514
515 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
516 const state = sessions.get(threadId);
517 if (!state) {
518 return [];
519 }
520 return [...state.rollbackCalls];
521 };
522
523 const getStartCount = (): number => sessionCount;
524
525 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
526 const calls = interruptCallsBySession.get(threadId);
527 if (!calls) {
528 return [];
529 }
530 return [...calls];
531 };
532
533 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
534 Array.from(sessions.values(), (state) => state.session.threadId);
535
536 const getApprovalResponses = (
537 threadId: ThreadId,
538 ): ReadonlyArray<{
539 readonly threadId: ThreadId;
540 readonly requestId: ApprovalRequestId;
541 readonly decision: ProviderApprovalDecision;
542 }> => {
543 const responses = approvalResponsesBySession.get(threadId);
544 if (!responses) {
545 return [];
546 }
547 return [...responses];
548 };
549
550 return {
551 adapter,
552 provider,
553 queueTurnResponse,
554 queueTurnResponseForNextSession,
555 getStartCount,
556 getRollbackCalls,
557 getInterruptCalls,
558 listActiveSessionIds,
559 getApprovalResponses,
560 } satisfies TestProviderAdapterHarness;
561});
506
507 const queueTurnResponseForNextSession = (
508 response: TestTurnResponse,
509 ): Effect.Effect<void, never> =>
510 Effect.sync(() => {
511 queuedResponsesForNextSession.push(response);
512 });
513
514 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
515 const state = sessions.get(threadId);
516 if (!state) {
517 return [];
518 }
519 return [...state.rollbackCalls];
520 };
521
522 const getStartCount = (): number => sessionCount;
523
524 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
525 const calls = interruptCallsBySession.get(threadId);
526 if (!calls) {
527 return [];
528 }
529 return [...calls];
530 };
531
532 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
533 Array.from(sessions.values(), (state) => state.session.threadId);
534
535 const getApprovalResponses = (
536 threadId: ThreadId,
537 ): ReadonlyArray<{
538 readonly threadId: ThreadId;
539 readonly requestId: ApprovalRequestId;
540 readonly decision: ProviderApprovalDecision;
541 }> => {
542 const responses = approvalResponsesBySession.get(threadId);
543 if (!responses) {
544 return [];
545 }
546 return [...responses];
547 };
548
549 return {
550 adapter,
551 provider,
552 queueTurnResponse,
553 queueTurnResponseForNextSession,
554 getStartCount,
555 getRollbackCalls,
556 getInterruptCalls,
557 listActiveSessionIds,
558 getApprovalResponses,
559 } satisfies TestProviderAdapterHarness;
560 });
apps/server/scripts/cli.ts+5-4
......@@ -6,7 +6,10 @@ import { Data, Effect, FileSystem, Logger, Option, Path } from "effect";
66import { Command, Flag } from "effect/unstable/cli";
77import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
88
9import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";
9import {
10 DEVELOPMENT_ICON_OVERRIDES,
11 PUBLISH_ICON_OVERRIDES,
12} from "../../../scripts/lib/brand-assets.ts";
1013import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
1114import rootPackageJson from "../../../package.json" with { type: "json" };
1215import serverPackageJson from "../package.json" with { type: "json" };
......@@ -237,9 +240,7 @@ const publishCmd = Command.make(
237240 Effect.gen(function* () {
238241 yield* restorePublishIconOverrides(resource.iconBackups).pipe(
239242 Effect.catch((error) =>
240 Effect.logError(
241 `[cli] Failed to restore publish icon overrides: ${String(error)}`,
242 ),
243 Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`),
243244 ),
244245 );
245246 yield* fs.rename(backupPath, packageJsonPath);
apps/server/src/attachmentStore.ts+1-2
......@@ -12,8 +12,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"
1212const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
1313const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
1414const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
15const ATTACHMENT_ID_UUID_PATTERN =
16 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
15const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
1716const ATTACHMENT_ID_PATTERN = new RegExp(
1817 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
1918 "i",
apps/server/src/codexAppServerManager.test.ts+60-66
......@@ -749,88 +749,82 @@ describe("respondToUserInput", () => {
749749});
750750
751751describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
752 it(
753 "keeps prior thread history when resuming with a changed runtime mode",
754 async () => {
755 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
756 writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8");
752 it("keeps prior thread history when resuming with a changed runtime mode", async () => {
753 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
754 writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8");
757755
758 const manager = new CodexAppServerManager();
756 const manager = new CodexAppServerManager();
759757
760 try {
761 const firstSession = await manager.startSession({
762 threadId: asThreadId("thread-live"),
763 provider: "codex",
764 cwd: workspaceDir,
765 runtimeMode: "full-access",
766 providerOptions: {
767 codex: {
768 ...(process.env.CODEX_BINARY_PATH
769 ? { binaryPath: process.env.CODEX_BINARY_PATH }
770 : {}),
771 ...(process.env.CODEX_HOME_PATH
772 ? { homePath: process.env.CODEX_HOME_PATH }
773 : {}),
774 },
758 try {
759 const firstSession = await manager.startSession({
760 threadId: asThreadId("thread-live"),
761 provider: "codex",
762 cwd: workspaceDir,
763 runtimeMode: "full-access",
764 providerOptions: {
765 codex: {
766 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
767 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
775768 },
776 });
769 },
770 });
777771
778 const firstTurn = await manager.sendTurn({
779 threadId: firstSession.threadId,
780 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
781 });
772 const firstTurn = await manager.sendTurn({
773 threadId: firstSession.threadId,
774 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
775 });
782776
783 expect(firstTurn.threadId).toBe(firstSession.threadId);
777 expect(firstTurn.threadId).toBe(firstSession.threadId);
784778
785 await vi.waitFor(async () => {
779 await vi.waitFor(
780 async () => {
786781 const snapshot = await manager.readThread(firstSession.threadId);
787782 expect(snapshot.turns.length).toBeGreaterThan(0);
788 }, { timeout: 120_000, interval: 1_000 });
783 },
784 { timeout: 120_000, interval: 1_000 },
785 );
789786
790 const firstSnapshot = await manager.readThread(firstSession.threadId);
791 const originalThreadId = firstSnapshot.threadId;
792 const originalTurnCount = firstSnapshot.turns.length;
787 const firstSnapshot = await manager.readThread(firstSession.threadId);
788 const originalThreadId = firstSnapshot.threadId;
789 const originalTurnCount = firstSnapshot.turns.length;
793790
794 manager.stopSession(firstSession.threadId);
791 manager.stopSession(firstSession.threadId);
795792
796 const resumedSession = await manager.startSession({
797 threadId: firstSession.threadId,
798 provider: "codex",
799 cwd: workspaceDir,
800 runtimeMode: "approval-required",
801 resumeCursor: firstSession.resumeCursor,
802 providerOptions: {
803 codex: {
804 ...(process.env.CODEX_BINARY_PATH
805 ? { binaryPath: process.env.CODEX_BINARY_PATH }
806 : {}),
807 ...(process.env.CODEX_HOME_PATH
808 ? { homePath: process.env.CODEX_HOME_PATH }
809 : {}),
810 },
793 const resumedSession = await manager.startSession({
794 threadId: firstSession.threadId,
795 provider: "codex",
796 cwd: workspaceDir,
797 runtimeMode: "approval-required",
798 resumeCursor: firstSession.resumeCursor,
799 providerOptions: {
800 codex: {
801 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
802 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
811803 },
812 });
804 },
805 });
813806
814 expect(resumedSession.threadId).toBe(originalThreadId);
807 expect(resumedSession.threadId).toBe(originalThreadId);
815808
816 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);
817 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
818 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
809 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);
810 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
811 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
819812
820 await manager.sendTurn({
821 threadId: resumedSession.threadId,
822 input: `Reply with exactly the word BETA ${randomUUID()}`,
823 });
813 await manager.sendTurn({
814 threadId: resumedSession.threadId,
815 input: `Reply with exactly the word BETA ${randomUUID()}`,
816 });
824817
825 await vi.waitFor(async () => {
818 await vi.waitFor(
819 async () => {
826820 const snapshot = await manager.readThread(resumedSession.threadId);
827821 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
828 }, { timeout: 120_000, interval: 1_000 });
829 } finally {
830 manager.stopAll();
831 rmSync(workspaceDir, { recursive: true, force: true });
832 }
833 },
834 180_000,
835 );
822 },
823 { timeout: 120_000, interval: 1_000 },
824 );
825 } finally {
826 manager.stopAll();
827 rmSync(workspaceDir, { recursive: true, force: true });
828 }
829 }, 180_000);
836830});
apps/server/src/git/Layers/CodexTextGeneration.test.ts+2-5
......@@ -363,8 +363,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363363 Effect.gen(function* () {
364364 const fs = yield* FileSystem.FileSystem;
365365 const path = yield* Path.Path;
366 const attachmentId =
367 `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
366 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
368367 const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);
369368 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });
370369 yield* fs.writeFile(imagePath, Buffer.from("hello"));
......@@ -392,9 +391,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
392391 }),
393392 ),
394393 ),
395 Effect.ensuring(
396 fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)),
397 ),
394 Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))),
398395 );
399396
400397 expect(generated.branch).toBe("fix/ui-regression");
apps/server/src/git/Layers/GitCore.test.ts+39-45
......@@ -153,9 +153,7 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0])
153153 });
154154}
155155
156function fetchGitPullRequestBranch(
157 input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0],
158) {
156function fetchGitPullRequestBranch(input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0]) {
159157 return Effect.gen(function* () {
160158 const core = yield* GitCore;
161159 return yield* core.fetchPullRequestBranch(input);
......@@ -441,7 +439,9 @@ it.layer(TestLayer)("git integration", (it) => {
441439 true,
442440 );
443441 expect(
444 result.branches.some((branch) => branch.name === "feature/local-only" && !branch.isRemote),
442 result.branches.some(
443 (branch) => branch.name === "feature/local-only" && !branch.isRemote,
444 ),
445445 ).toBe(true);
446446 expect(
447447 result.branches.some(
......@@ -704,29 +704,27 @@ it.layer(TestLayer)("git integration", (it) => {
704704 }),
705705 );
706706
707 it.effect(
708 "does not silently checkout a local branch when a remote ref no longer exists",
709 () =>
710 Effect.gen(function* () {
711 const remote = yield* makeTmpDir();
712 const source = yield* makeTmpDir();
713 yield* git(remote, ["init", "--bare"]);
707 it.effect("does not silently checkout a local branch when a remote ref no longer exists", () =>
708 Effect.gen(function* () {
709 const remote = yield* makeTmpDir();
710 const source = yield* makeTmpDir();
711 yield* git(remote, ["init", "--bare"]);
714712
715 yield* initRepoWithCommit(source);
716 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
717 (branch) => branch.current,
718 )!.name;
719 yield* git(source, ["remote", "add", "origin", remote]);
720 yield* git(source, ["push", "-u", "origin", defaultBranch]);
713 yield* initRepoWithCommit(source);
714 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
715 (branch) => branch.current,
716 )!.name;
717 yield* git(source, ["remote", "add", "origin", remote]);
718 yield* git(source, ["push", "-u", "origin", defaultBranch]);
721719
722 yield* createGitBranch({ cwd: source, branch: "feature" });
720 yield* createGitBranch({ cwd: source, branch: "feature" });
723721
724 const checkoutResult = yield* Effect.result(
725 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),
726 );
727 expect(checkoutResult._tag).toBe("Failure");
728 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
729 }),
722 const checkoutResult = yield* Effect.result(
723 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),
724 );
725 expect(checkoutResult._tag).toBe("Failure");
726 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
727 }),
730728 );
731729
732730 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>
......@@ -955,13 +953,7 @@ it.layer(TestLayer)("git integration", (it) => {
955953 });
956954
957955 expect(renamed.branch).toBe("feature/new-name");
958 expect(renameArgs).toEqual([
959 "branch",
960 "-m",
961 "--",
962 "feature/old-name",
963 "feature/new-name",
964 ]);
956 expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]);
965957 }),
966958 );
967959 });
......@@ -1325,23 +1317,25 @@ it.layer(TestLayer)("git integration", (it) => {
13251317 }),
13261318 );
13271319
1328 it.effect("reuses an existing remote when the target URL only differs by a trailing slash after .git", () =>
1329 Effect.gen(function* () {
1330 const tmp = yield* makeTmpDir();
1331 yield* initRepoWithCommit(tmp);
1332 const core = yield* GitCore;
1320 it.effect(
1321 "reuses an existing remote when the target URL only differs by a trailing slash after .git",
1322 () =>
1323 Effect.gen(function* () {
1324 const tmp = yield* makeTmpDir();
1325 yield* initRepoWithCommit(tmp);
1326 const core = yield* GitCore;
13331327
1334 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);
1328 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);
13351329
1336 const remoteName = yield* core.ensureRemote({
1337 cwd: tmp,
1338 preferredName: "origin",
1339 url: "git@github.com:pingdotgg/t3code.git/",
1340 });
1330 const remoteName = yield* core.ensureRemote({
1331 cwd: tmp,
1332 preferredName: "origin",
1333 url: "git@github.com:pingdotgg/t3code.git/",
1334 });
13411335
1342 expect(remoteName).toBe("origin");
1343 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);
1344 }),
1336 expect(remoteName).toBe("origin");
1337 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);
1338 }),
13451339 );
13461340
13471341 it.effect("reports status details and dirty state", () =>
apps/server/src/git/Layers/GitCore.ts+23-15
......@@ -101,12 +101,19 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {
101101}
102102
103103function sanitizeRemoteName(value: string): string {
104 const sanitized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
104 const sanitized = value
105 .trim()
106 .replace(/[^A-Za-z0-9._-]+/g, "-")
107 .replace(/^-+|-+$/g, "");
105108 return sanitized.length > 0 ? sanitized : "fork";
106109}
107110
108111function normalizeRemoteUrl(value: string): string {
109 return value.trim().replace(/\/+$/g, "").replace(/\.git$/i, "").toLowerCase();
112 return value
113 .trim()
114 .replace(/\/+$/g, "")
115 .replace(/\.git$/i, "")
116 .toLowerCase();
110117}
111118
112119function parseRemoteFetchUrls(stdout: string): Map<string, string> {
......@@ -470,10 +477,11 @@ const makeGitCore = Effect.gen(function* () {
470477 Effect.gen(function* () {
471478 const preferredName = sanitizeRemoteName(input.preferredName);
472479 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
473 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [
474 "remote",
475 "-v",
476 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
480 const remoteFetchUrls = yield* runGitStdout(
481 "GitCore.ensureRemote.listRemoteUrls",
482 input.cwd,
483 ["remote", "-v"],
484 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
477485
478486 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
479487 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
......@@ -488,7 +496,12 @@ const makeGitCore = Effect.gen(function* () {
488496 suffix += 1;
489497 }
490498
491 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);
499 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
500 "remote",
501 "add",
502 remoteName,
503 input.url,
504 ]);
492505 return remoteName;
493506 });
494507
......@@ -1099,14 +1112,9 @@ const makeGitCore = Effect.gen(function* () {
10991112 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
11001113 : ["worktree", "add", worktreePath, input.branch];
11011114
1102 yield* executeGit(
1103 "GitCore.createWorktree",
1104 input.cwd,
1105 args,
1106 {
1107 fallbackErrorMessage: "git worktree add failed",
1108 },
1109 );
1115 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1116 fallbackErrorMessage: "git worktree add failed",
1117 });
11101118
11111119 return {
11121120 worktree: {
apps/server/src/git/Layers/GitManager.test.ts+137-125
......@@ -120,7 +120,11 @@ function runGit(
120120
121121function initRepo(
122122 cwd: string,
123): Effect.Effect<void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService> {
123): Effect.Effect<
124 void,
125 PlatformError.PlatformError | GitCommandError,
126 FileSystem.FileSystem | Scope.Scope | GitService
127> {
124128 return Effect.gen(function* () {
125129 const fs = yield* FileSystem.FileSystem;
126130 yield* runGit(cwd, ["init", "--initial-branch=main"]);
......@@ -150,9 +154,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
150154 Effect.succeed({
151155 subject: "Implement stacked git actions",
152156 body: "",
153 ...(input.includeBranch
154 ? { branch: "feature/implement-stacked-git-actions" }
155 : {}),
157 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
156158 }),
157159 generatePrContent: () =>
158160 Effect.succeed({
......@@ -416,9 +418,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
416418 "--json",
417419 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
418420 ],
419 }).pipe(
420 Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary),
421 ),
421 }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)),
422422 getRepositoryCloneUrls: (input) =>
423423 execute({
424424 cwd: input.cwd,
......@@ -427,12 +427,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
427427 checkoutPullRequest: (input) =>
428428 execute({
429429 cwd: input.cwd,
430 args: [
431 "pr",
432 "checkout",
433 input.reference,
434 ...(input.force ? ["--force"] : []),
435 ],
430 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
436431 }).pipe(Effect.asVoid),
437432 },
438433 ghCalls,
......@@ -722,9 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
722717 return {
723718 subject: "Implement stacked git actions",
724719 body: "",
725 ...(input.includeBranch
726 ? { branch: "feature/implement-stacked-git-actions" }
727 : {}),
720 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
728721 };
729722 }),
730723 },
......@@ -1199,9 +1192,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11991192 expect(result.branch).toBe("feature/pr-worktree");
12001193 expect(result.worktreePath).not.toBeNull();
12011194 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
1202 const worktreeBranch = (
1203 yield* runGit(result.worktreePath as string, ["branch", "--show-current"])
1204 ).stdout.trim();
1195 const worktreeBranch = (yield* runGit(result.worktreePath as string, [
1196 "branch",
1197 "--show-current",
1198 ])).stdout.trim();
12051199 expect(worktreeBranch).toBe("feature/pr-worktree");
12061200 }),
12071201 );
......@@ -1251,15 +1245,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12511245 });
12521246
12531247 expect(result.worktreePath).not.toBeNull();
1254 const upstreamRef = (
1255 yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"])
1256 ).stdout.trim();
1248 const upstreamRef = (yield* runGit(result.worktreePath as string, [
1249 "rev-parse",
1250 "--abbrev-ref",
1251 "@{upstream}",
1252 ])).stdout.trim();
12571253 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
12581254 expect(upstreamRef.startsWith("origin/")).toBe(false);
12591255 expect(
1260 (yield* runGit(result.worktreePath as string, ["config", "--get", "remote.fork-seed.url"]))
1261 .stdout
1262 .trim(),
1256 (yield* runGit(result.worktreePath as string, [
1257 "config",
1258 "--get",
1259 "remote.fork-seed.url",
1260 ])).stdout.trim(),
12631261 ).toBe(forkDir);
12641262 }),
12651263 );
......@@ -1330,7 +1328,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13301328 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");
13311329 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
13321330 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
1333 yield* runGit(repoDir, ["push", "-u", "binbandit-seed", "fix/git-action-default-without-origin"]);
1331 yield* runGit(repoDir, [
1332 "push",
1333 "-u",
1334 "binbandit-seed",
1335 "fix/git-action-default-without-origin",
1336 ]);
13341337 yield* runGit(repoDir, ["checkout", "main"]);
13351338 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
13361339
......@@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
14071410 }),
14081411 );
14091412
1410 it.effect("does not block fork PR worktree prep when the fork head branch collides with root main", () =>
1411 Effect.gen(function* () {
1412 const repoDir = yield* makeTempDir("t3code-git-manager-");
1413 yield* initRepo(repoDir);
1414 const originDir = yield* createBareRemote();
1415 const forkDir = yield* createBareRemote();
1416 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1417 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1418 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1419 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1420 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
1421 yield* runGit(repoDir, ["add", "fork-main.txt"]);
1422 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
1423 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1424 yield* runGit(repoDir, ["checkout", "main"]);
1425 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1426
1427 const { manager } = yield* makeManager({
1428 ghScenario: {
1429 pullRequest: {
1430 number: 91,
1431 title: "Fork main PR",
1432 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
1433 baseRefName: "main",
1434 headRefName: "main",
1435 state: "open",
1436 isCrossRepository: true,
1437 headRepositoryNameWithOwner: "octocat/codething-mvp",
1438 headRepositoryOwnerLogin: "octocat",
1439 },
1440 repositoryCloneUrls: {
1441 "octocat/codething-mvp": {
1442 url: forkDir,
1443 sshUrl: forkDir,
1413 it.effect(
1414 "does not block fork PR worktree prep when the fork head branch collides with root main",
1415 () =>
1416 Effect.gen(function* () {
1417 const repoDir = yield* makeTempDir("t3code-git-manager-");
1418 yield* initRepo(repoDir);
1419 const originDir = yield* createBareRemote();
1420 const forkDir = yield* createBareRemote();
1421 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1422 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1423 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1424 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1425 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
1426 yield* runGit(repoDir, ["add", "fork-main.txt"]);
1427 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
1428 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1429 yield* runGit(repoDir, ["checkout", "main"]);
1430 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1431
1432 const { manager } = yield* makeManager({
1433 ghScenario: {
1434 pullRequest: {
1435 number: 91,
1436 title: "Fork main PR",
1437 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
1438 baseRefName: "main",
1439 headRefName: "main",
1440 state: "open",
1441 isCrossRepository: true,
1442 headRepositoryNameWithOwner: "octocat/codething-mvp",
1443 headRepositoryOwnerLogin: "octocat",
1444 },
1445 repositoryCloneUrls: {
1446 "octocat/codething-mvp": {
1447 url: forkDir,
1448 sshUrl: forkDir,
1449 },
14441450 },
14451451 },
1446 },
1447 });
1452 });
14481453
1449 const result = yield* preparePullRequestThread(manager, {
1450 cwd: repoDir,
1451 reference: "91",
1452 mode: "worktree",
1453 });
1454 const result = yield* preparePullRequestThread(manager, {
1455 cwd: repoDir,
1456 reference: "91",
1457 mode: "worktree",
1458 });
14541459
1455 expect(result.branch).toBe("t3code/pr-91/main");
1456 expect(result.worktreePath).not.toBeNull();
1457 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1458 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
1459 expect(
1460 (yield* runGit(result.worktreePath as string, ["branch", "--show-current"])).stdout.trim(),
1461 ).toBe("t3code/pr-91/main");
1462 }),
1460 expect(result.branch).toBe("t3code/pr-91/main");
1461 expect(result.worktreePath).not.toBeNull();
1462 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1463 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
1464 expect(
1465 (yield* runGit(result.worktreePath as string, [
1466 "branch",
1467 "--show-current",
1468 ])).stdout.trim(),
1469 ).toBe("t3code/pr-91/main");
1470 }),
14631471 );
14641472
1465 it.effect("does not overwrite an existing local main branch when preparing a fork PR worktree", () =>
1466 Effect.gen(function* () {
1467 const repoDir = yield* makeTempDir("t3code-git-manager-");
1468 yield* initRepo(repoDir);
1469 const originDir = yield* createBareRemote();
1470 const forkDir = yield* createBareRemote();
1471 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1472 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1473 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1474 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1475 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
1476 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
1477 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
1478 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1479 yield* runGit(repoDir, ["checkout", "main"]);
1480 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1481 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
1482
1483 const { manager } = yield* makeManager({
1484 ghScenario: {
1485 pullRequest: {
1486 number: 92,
1487 title: "Fork main overwrite PR",
1488 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
1489 baseRefName: "main",
1490 headRefName: "main",
1491 state: "open",
1492 isCrossRepository: true,
1493 headRepositoryNameWithOwner: "octocat/codething-mvp",
1494 headRepositoryOwnerLogin: "octocat",
1495 },
1496 repositoryCloneUrls: {
1497 "octocat/codething-mvp": {
1498 url: forkDir,
1499 sshUrl: forkDir,
1473 it.effect(
1474 "does not overwrite an existing local main branch when preparing a fork PR worktree",
1475 () =>
1476 Effect.gen(function* () {
1477 const repoDir = yield* makeTempDir("t3code-git-manager-");
1478 yield* initRepo(repoDir);
1479 const originDir = yield* createBareRemote();
1480 const forkDir = yield* createBareRemote();
1481 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1482 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1483 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1484 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1485 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
1486 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
1487 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
1488 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1489 yield* runGit(repoDir, ["checkout", "main"]);
1490 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1491 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
1492
1493 const { manager } = yield* makeManager({
1494 ghScenario: {
1495 pullRequest: {
1496 number: 92,
1497 title: "Fork main overwrite PR",
1498 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
1499 baseRefName: "main",
1500 headRefName: "main",
1501 state: "open",
1502 isCrossRepository: true,
1503 headRepositoryNameWithOwner: "octocat/codething-mvp",
1504 headRepositoryOwnerLogin: "octocat",
1505 },
1506 repositoryCloneUrls: {
1507 "octocat/codething-mvp": {
1508 url: forkDir,
1509 sshUrl: forkDir,
1510 },
15001511 },
15011512 },
1502 },
1503 });
1513 });
15041514
1505 const result = yield* preparePullRequestThread(manager, {
1506 cwd: repoDir,
1507 reference: "92",
1508 mode: "worktree",
1509 });
1515 const result = yield* preparePullRequestThread(manager, {
1516 cwd: repoDir,
1517 reference: "92",
1518 mode: "worktree",
1519 });
15101520
1511 expect(result.branch).toBe("t3code/pr-92/main");
1512 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
1513 expect(
1514 (yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"]))
1515 .stdout
1516 .trim(),
1517 ).toBe("fork-seed/main");
1518 }),
1521 expect(result.branch).toBe("t3code/pr-92/main");
1522 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
1523 expect(
1524 (yield* runGit(result.worktreePath as string, [
1525 "rev-parse",
1526 "--abbrev-ref",
1527 "@{upstream}",
1528 ])).stdout.trim(),
1529 ).toBe("fork-seed/main");
1530 }),
15191531 );
15201532
15211533 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
apps/server/src/git/Layers/GitManager.ts+20-22
......@@ -655,28 +655,27 @@ export const makeGitManager = Effect.gen(function* () {
655655 };
656656 });
657657
658 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(function* (
659 input,
660 ) {
661 const pullRequest = yield* gitHubCli
662 .getPullRequest({
663 cwd: input.cwd,
664 reference: normalizePullRequestReference(input.reference),
665 })
666 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
658 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(
659 function* (input) {
660 const pullRequest = yield* gitHubCli
661 .getPullRequest({
662 cwd: input.cwd,
663 reference: normalizePullRequestReference(input.reference),
664 })
665 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
667666
668 return { pullRequest };
669 });
667 return { pullRequest };
668 },
669 );
670670
671671 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced(
672672 function* (input) {
673673 const normalizedReference = normalizePullRequestReference(input.reference);
674674 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
675 const pullRequestSummary = yield* gitHubCli
676 .getPullRequest({
677 cwd: input.cwd,
678 reference: normalizedReference,
679 });
675 const pullRequestSummary = yield* gitHubCli.getPullRequest({
676 cwd: input.cwd,
677 reference: normalizedReference,
678 });
680679 const pullRequest = toResolvedPullRequest(pullRequestSummary);
681680
682681 if (input.mode === "local") {
......@@ -715,12 +714,11 @@ export const makeGitManager = Effect.gen(function* () {
715714 });
716715
717716 const pullRequestWithRemoteInfo = {
718 ...pullRequest,
719 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
720 } as const;
721 const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(
722 pullRequestWithRemoteInfo,
723 );
717 ...pullRequest,
718 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
719 } as const;
720 const localPullRequestBranch =
721 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
724722
725723 const findLocalHeadBranch = (cwd: string) =>
726724 gitCore.listBranches({ cwd }).pipe(
apps/server/src/git/Services/GitCore.ts+1-3
......@@ -165,9 +165,7 @@ export interface GitCoreShape {
165165 /**
166166 * Ensure a named remote exists for the provided URL, returning the reused or created remote name.
167167 */
168 readonly ensureRemote: (
169 input: GitEnsureRemoteInput,
170 ) => Effect.Effect<string, GitCommandError>;
168 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
171169
172170 /**
173171 * Fetch a remote branch into a local branch without checkout.
apps/server/src/keybindings.test.ts+3-1
......@@ -211,7 +211,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
211211 });
212212
213213 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
214 assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === "invalid.command"));
214 assert.isFalse(
215 configState.keybindings.some((entry) => String(entry.command) === "invalid.command"),
216 );
215217 assert.deepEqual(configState.issues, [
216218 {
217219 kind: "keybindings.invalid-entry",
apps/server/src/open.ts+4-4
......@@ -40,7 +40,9 @@ interface CommandAvailabilityOptions {
4040const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
4141
4242function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
43 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);
43 return (
44 (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target)
45 );
4446}
4547
4648function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
......@@ -239,9 +241,7 @@ export const launchDetached = (launch: EditorLaunch) =>
239241 });
240242 } catch (error) {
241243 return resume(
242 Effect.fail(
243 new OpenError({ message: "failed to spawn detached process", cause: error }),
244 ),
244 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
245245 );
246246 }
247247
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+11-11
......@@ -69,7 +69,7 @@ function createProviderServiceHarness(
6969 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
7070 const listSessions = () =>
7171 hasSession
72 ? Effect.succeed([
72 ? Effect.succeed([
7373 {
7474 provider: providerName,
7575 status: "ready",
......@@ -350,7 +350,7 @@ describe("CheckpointReactor", () => {
350350 type: "turn.started",
351351 eventId: EventId.makeUnsafe("evt-turn-started-1"),
352352 provider: "codex",
353
353
354354 createdAt: new Date().toISOString(),
355355 threadId: ThreadId.makeUnsafe("thread-1"),
356356 turnId: asTurnId("turn-1"),
......@@ -365,7 +365,7 @@ describe("CheckpointReactor", () => {
365365 type: "turn.completed",
366366 eventId: EventId.makeUnsafe("evt-turn-completed-1"),
367367 provider: "codex",
368
368
369369 createdAt: new Date().toISOString(),
370370 threadId: ThreadId.makeUnsafe("thread-1"),
371371 turnId: asTurnId("turn-1"),
......@@ -426,7 +426,7 @@ describe("CheckpointReactor", () => {
426426 type: "turn.started",
427427 eventId: EventId.makeUnsafe("evt-turn-started-main"),
428428 provider: "codex",
429
429
430430 createdAt: new Date().toISOString(),
431431 threadId: ThreadId.makeUnsafe("thread-1"),
432432 turnId: asTurnId("turn-main"),
......@@ -442,7 +442,7 @@ describe("CheckpointReactor", () => {
442442 type: "turn.completed",
443443 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),
444444 provider: "codex",
445
445
446446 createdAt: new Date().toISOString(),
447447 threadId: ThreadId.makeUnsafe("thread-1"),
448448 turnId: asTurnId("turn-aux"),
......@@ -460,7 +460,7 @@ describe("CheckpointReactor", () => {
460460 type: "turn.completed",
461461 eventId: EventId.makeUnsafe("evt-turn-completed-main"),
462462 provider: "codex",
463
463
464464 createdAt: new Date().toISOString(),
465465 threadId: ThreadId.makeUnsafe("thread-1"),
466466 turnId: asTurnId("turn-main"),
......@@ -500,7 +500,7 @@ describe("CheckpointReactor", () => {
500500 type: "turn.completed",
501501 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),
502502 provider: "codex",
503
503
504504 createdAt: new Date().toISOString(),
505505 threadId: ThreadId.makeUnsafe("thread-1"),
506506 turnId: asTurnId("turn-missing-baseline"),
......@@ -589,7 +589,7 @@ describe("CheckpointReactor", () => {
589589 type: "turn.completed",
590590 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
591591 provider: "codex",
592
592
593593 createdAt: new Date().toISOString(),
594594 threadId: ThreadId.makeUnsafe("thread-1"),
595595 turnId: asTurnId("turn-missing-cwd"),
......@@ -635,7 +635,7 @@ describe("CheckpointReactor", () => {
635635 type: "checkpoint.captured",
636636 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
637637 provider: "codex",
638
638
639639 createdAt: new Date().toISOString(),
640640 threadId: ThreadId.makeUnsafe("thread-1"),
641641 turnId: asTurnId("turn-3"),
......@@ -685,7 +685,7 @@ describe("CheckpointReactor", () => {
685685 type: "turn.completed",
686686 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
687687 provider: "codex",
688
688
689689 createdAt: new Date().toISOString(),
690690 threadId: ThreadId.makeUnsafe("thread-1"),
691691 turnId: asTurnId("turn-runtime-failure"),
......@@ -696,7 +696,7 @@ describe("CheckpointReactor", () => {
696696 type: "turn.started",
697697 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
698698 provider: "codex",
699
699
700700 createdAt: new Date().toISOString(),
701701 threadId: ThreadId.makeUnsafe("thread-1"),
702702 turnId: asTurnId("turn-after-runtime-failure"),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-9
......@@ -115,9 +115,7 @@ const make = Effect.gen(function* () {
115115
116116 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (
117117 threadId: ThreadId,
118 ): Effect.fn.Return<
119 Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>
120 > {
118 ): Effect.fn.Return<Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>> {
121119 const readModel = yield* orchestrationEngine.getReadModel();
122120 const thread = readModel.threads.find((entry) => entry.id === threadId);
123121
......@@ -133,9 +131,7 @@ const make = Effect.gen(function* () {
133131 };
134132
135133 if (thread) {
136 const projectedSession = sessions.find(
137 (session) => session.threadId === thread.id,
138 );
134 const projectedSession = sessions.find((session) => session.threadId === thread.id);
139135 const fromProjected = findSessionWithCwd(projectedSession);
140136 if (Option.isSome(fromProjected)) {
141137 return fromProjected;
......@@ -306,9 +302,7 @@ const make = Effect.gen(function* () {
306302 }
307303
308304 const readModel = yield* orchestrationEngine.getReadModel();
309 const thread = readModel.threads.find(
310 (entry) => entry.id === event.threadId,
311 );
305 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
312306 if (!thread) {
313307 return;
314308 }
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+407-415
......@@ -54,9 +54,7 @@ const runWithProjectionPipelineLayer = <A, E>(
5454 (runtime) => Effect.promise(() => runtime.dispose()),
5555 );
5656
57const projectionLayer = it.layer(
58 makeProjectionPipelineTestLayer(process.cwd()),
59);
57const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd()));
6058
6159projectionLayer("OrchestrationProjectionPipeline", (it) => {
6260 it.effect("bootstraps all projection states and writes projection rows", () =>
......@@ -450,153 +448,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
450448 }),
451449 );
452450
453 it.effect(
454 "overwrites stored attachment references when a message updates attachments",
455 () =>
456 Effect.sync(() =>
457 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
458 ).pipe(
459 Effect.flatMap((stateDir) =>
460 Effect.gen(function* () {
461 const projectionPipeline = yield* OrchestrationProjectionPipeline;
462 const eventStore = yield* OrchestrationEventStore;
463 const sql = yield* SqlClient.SqlClient;
464 const now = new Date().toISOString();
465 const later = new Date(Date.now() + 1_000).toISOString();
466
467 yield* eventStore.append({
468 type: "project.created",
469 eventId: EventId.makeUnsafe("evt-overwrite-1"),
470 aggregateKind: "project",
471 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
472 occurredAt: now,
473 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
474 causationEventId: null,
475 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
476 metadata: {},
477 payload: {
478 projectId: ProjectId.makeUnsafe("project-overwrite"),
479 title: "Project Overwrite",
480 workspaceRoot: "/tmp/project-overwrite",
481 defaultModel: null,
482 scripts: [],
483 createdAt: now,
484 updatedAt: now,
485 },
486 });
451 it.effect("overwrites stored attachment references when a message updates attachments", () =>
452 Effect.sync(() =>
453 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
454 ).pipe(
455 Effect.flatMap((stateDir) =>
456 Effect.gen(function* () {
457 const projectionPipeline = yield* OrchestrationProjectionPipeline;
458 const eventStore = yield* OrchestrationEventStore;
459 const sql = yield* SqlClient.SqlClient;
460 const now = new Date().toISOString();
461 const later = new Date(Date.now() + 1_000).toISOString();
487462
488 yield* eventStore.append({
489 type: "thread.created",
490 eventId: EventId.makeUnsafe("evt-overwrite-2"),
491 aggregateKind: "thread",
492 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
493 occurredAt: now,
494 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
495 causationEventId: null,
496 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
497 metadata: {},
498 payload: {
499 threadId: ThreadId.makeUnsafe("thread-overwrite"),
500 projectId: ProjectId.makeUnsafe("project-overwrite"),
501 title: "Thread Overwrite",
502 model: "gpt-5-codex",
503 runtimeMode: "full-access",
504 branch: null,
505 worktreePath: null,
506 createdAt: now,
507 updatedAt: now,
508 },
509 });
463 yield* eventStore.append({
464 type: "project.created",
465 eventId: EventId.makeUnsafe("evt-overwrite-1"),
466 aggregateKind: "project",
467 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
468 occurredAt: now,
469 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
470 causationEventId: null,
471 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
472 metadata: {},
473 payload: {
474 projectId: ProjectId.makeUnsafe("project-overwrite"),
475 title: "Project Overwrite",
476 workspaceRoot: "/tmp/project-overwrite",
477 defaultModel: null,
478 scripts: [],
479 createdAt: now,
480 updatedAt: now,
481 },
482 });
510483
511 yield* eventStore.append({
512 type: "thread.message-sent",
513 eventId: EventId.makeUnsafe("evt-overwrite-3"),
514 aggregateKind: "thread",
515 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
516 occurredAt: now,
517 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
518 causationEventId: null,
519 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
520 metadata: {},
521 payload: {
522 threadId: ThreadId.makeUnsafe("thread-overwrite"),
523 messageId: MessageId.makeUnsafe("message-overwrite"),
524 role: "user",
525 text: "first image",
526 attachments: [
527 {
528 type: "image",
529 id: "thread-overwrite-att-1",
530 name: "file.png",
531 mimeType: "image/png",
532 sizeBytes: 5,
533 },
534 ],
535 turnId: null,
536 streaming: false,
537 createdAt: now,
538 updatedAt: now,
539 },
540 });
484 yield* eventStore.append({
485 type: "thread.created",
486 eventId: EventId.makeUnsafe("evt-overwrite-2"),
487 aggregateKind: "thread",
488 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
489 occurredAt: now,
490 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
491 causationEventId: null,
492 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
493 metadata: {},
494 payload: {
495 threadId: ThreadId.makeUnsafe("thread-overwrite"),
496 projectId: ProjectId.makeUnsafe("project-overwrite"),
497 title: "Thread Overwrite",
498 model: "gpt-5-codex",
499 runtimeMode: "full-access",
500 branch: null,
501 worktreePath: null,
502 createdAt: now,
503 updatedAt: now,
504 },
505 });
541506
542 yield* eventStore.append({
543 type: "thread.message-sent",
544 eventId: EventId.makeUnsafe("evt-overwrite-4"),
545 aggregateKind: "thread",
546 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
547 occurredAt: later,
548 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
549 causationEventId: null,
550 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
551 metadata: {},
552 payload: {
553 threadId: ThreadId.makeUnsafe("thread-overwrite"),
554 messageId: MessageId.makeUnsafe("message-overwrite"),
555 role: "user",
556 text: "",
557 attachments: [
558 {
559 type: "image",
560 id: "thread-overwrite-att-2",
561 name: "file.png",
562 mimeType: "image/png",
563 sizeBytes: 5,
564 },
565 ],
566 turnId: null,
567 streaming: false,
568 createdAt: now,
569 updatedAt: later,
570 },
571 });
507 yield* eventStore.append({
508 type: "thread.message-sent",
509 eventId: EventId.makeUnsafe("evt-overwrite-3"),
510 aggregateKind: "thread",
511 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
512 occurredAt: now,
513 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
514 causationEventId: null,
515 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
516 metadata: {},
517 payload: {
518 threadId: ThreadId.makeUnsafe("thread-overwrite"),
519 messageId: MessageId.makeUnsafe("message-overwrite"),
520 role: "user",
521 text: "first image",
522 attachments: [
523 {
524 type: "image",
525 id: "thread-overwrite-att-1",
526 name: "file.png",
527 mimeType: "image/png",
528 sizeBytes: 5,
529 },
530 ],
531 turnId: null,
532 streaming: false,
533 createdAt: now,
534 updatedAt: now,
535 },
536 });
572537
573 yield* projectionPipeline.bootstrap;
538 yield* eventStore.append({
539 type: "thread.message-sent",
540 eventId: EventId.makeUnsafe("evt-overwrite-4"),
541 aggregateKind: "thread",
542 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
543 occurredAt: later,
544 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
545 causationEventId: null,
546 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
547 metadata: {},
548 payload: {
549 threadId: ThreadId.makeUnsafe("thread-overwrite"),
550 messageId: MessageId.makeUnsafe("message-overwrite"),
551 role: "user",
552 text: "",
553 attachments: [
554 {
555 type: "image",
556 id: "thread-overwrite-att-2",
557 name: "file.png",
558 mimeType: "image/png",
559 sizeBytes: 5,
560 },
561 ],
562 turnId: null,
563 streaming: false,
564 createdAt: now,
565 updatedAt: later,
566 },
567 });
568
569 yield* projectionPipeline.bootstrap;
574570
575 const rows = yield* sql<{
576 readonly attachmentsJson: string | null;
577 }>`
571 const rows = yield* sql<{
572 readonly attachmentsJson: string | null;
573 }>`
578574 SELECT attachments_json AS "attachmentsJson"
579575 FROM projection_thread_messages
580576 WHERE message_id = 'message-overwrite'
581577 `;
582 assert.equal(rows.length, 1);
583 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
584 {
585 type: "image",
586 id: "thread-overwrite-att-2",
587 name: "file.png",
588 mimeType: "image/png",
589 sizeBytes: 5,
590 },
591 ]);
592 }).pipe(
593 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
594 Effect.ensuring(
595 Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true })),
596 ),
597 ),
578 assert.equal(rows.length, 1);
579 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
580 {
581 type: "image",
582 id: "thread-overwrite-att-2",
583 name: "file.png",
584 mimeType: "image/png",
585 sizeBytes: 5,
586 },
587 ]);
588 }).pipe(
589 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
590 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
598591 ),
599592 ),
593 ),
600594 );
601595
602596 it.effect("does not persist attachment files when projector transaction rolls back", () =>
......@@ -711,11 +705,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
711705 `;
712706 assert.equal(rows[0]?.count ?? 0, 0);
713707
714 const attachmentPath = path.join(
715 stateDir,
716 "attachments",
717 "thread-rollback-att-1.png",
718 );
708 const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png");
719709 assert.equal(fs.existsSync(attachmentPath), false);
720710 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
721711 }).pipe(
......@@ -747,189 +737,189 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
747737 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
748738
749739 yield* appendAndProject({
750 type: "project.created",
751 eventId: EventId.makeUnsafe("evt-revert-files-1"),
752 aggregateKind: "project",
753 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
754 occurredAt: now,
755 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
756 causationEventId: null,
757 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
758 metadata: {},
759 payload: {
760 projectId: ProjectId.makeUnsafe("project-revert-files"),
761 title: "Project Revert Files",
762 workspaceRoot: "/tmp/project-revert-files",
763 defaultModel: null,
764 scripts: [],
765 createdAt: now,
766 updatedAt: now,
767 },
768 });
740 type: "project.created",
741 eventId: EventId.makeUnsafe("evt-revert-files-1"),
742 aggregateKind: "project",
743 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
744 occurredAt: now,
745 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
746 causationEventId: null,
747 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
748 metadata: {},
749 payload: {
750 projectId: ProjectId.makeUnsafe("project-revert-files"),
751 title: "Project Revert Files",
752 workspaceRoot: "/tmp/project-revert-files",
753 defaultModel: null,
754 scripts: [],
755 createdAt: now,
756 updatedAt: now,
757 },
758 });
769759
770 yield* appendAndProject({
771 type: "thread.created",
772 eventId: EventId.makeUnsafe("evt-revert-files-2"),
773 aggregateKind: "thread",
774 aggregateId: threadId,
775 occurredAt: now,
776 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
777 causationEventId: null,
778 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
779 metadata: {},
780 payload: {
781 threadId,
782 projectId: ProjectId.makeUnsafe("project-revert-files"),
783 title: "Thread Revert Files",
784 model: "gpt-5-codex",
785 runtimeMode: "full-access",
786 branch: null,
787 worktreePath: null,
788 createdAt: now,
789 updatedAt: now,
790 },
791 });
760 yield* appendAndProject({
761 type: "thread.created",
762 eventId: EventId.makeUnsafe("evt-revert-files-2"),
763 aggregateKind: "thread",
764 aggregateId: threadId,
765 occurredAt: now,
766 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
767 causationEventId: null,
768 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
769 metadata: {},
770 payload: {
771 threadId,
772 projectId: ProjectId.makeUnsafe("project-revert-files"),
773 title: "Thread Revert Files",
774 model: "gpt-5-codex",
775 runtimeMode: "full-access",
776 branch: null,
777 worktreePath: null,
778 createdAt: now,
779 updatedAt: now,
780 },
781 });
792782
793 yield* appendAndProject({
794 type: "thread.turn-diff-completed",
795 eventId: EventId.makeUnsafe("evt-revert-files-3"),
796 aggregateKind: "thread",
797 aggregateId: threadId,
798 occurredAt: now,
799 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
800 causationEventId: null,
801 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
802 metadata: {},
803 payload: {
804 threadId,
805 turnId: TurnId.makeUnsafe("turn-keep"),
806 checkpointTurnCount: 1,
807 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
808 status: "ready",
809 files: [],
810 assistantMessageId: MessageId.makeUnsafe("message-keep"),
811 completedAt: now,
812 },
813 });
783 yield* appendAndProject({
784 type: "thread.turn-diff-completed",
785 eventId: EventId.makeUnsafe("evt-revert-files-3"),
786 aggregateKind: "thread",
787 aggregateId: threadId,
788 occurredAt: now,
789 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
790 causationEventId: null,
791 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
792 metadata: {},
793 payload: {
794 threadId,
795 turnId: TurnId.makeUnsafe("turn-keep"),
796 checkpointTurnCount: 1,
797 checkpointRef: CheckpointRef.makeUnsafe(
798 "refs/t3/checkpoints/thread-revert-files/turn/1",
799 ),
800 status: "ready",
801 files: [],
802 assistantMessageId: MessageId.makeUnsafe("message-keep"),
803 completedAt: now,
804 },
805 });
814806
815 yield* appendAndProject({
816 type: "thread.message-sent",
817 eventId: EventId.makeUnsafe("evt-revert-files-4"),
818 aggregateKind: "thread",
819 aggregateId: threadId,
820 occurredAt: now,
821 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
822 causationEventId: null,
823 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
824 metadata: {},
825 payload: {
826 threadId,
827 messageId: MessageId.makeUnsafe("message-keep"),
828 role: "assistant",
829 text: "Keep",
830 attachments: [
831 {
832 type: "image",
833 id: keepAttachmentId,
834 name: "keep.png",
835 mimeType: "image/png",
836 sizeBytes: 5,
807 yield* appendAndProject({
808 type: "thread.message-sent",
809 eventId: EventId.makeUnsafe("evt-revert-files-4"),
810 aggregateKind: "thread",
811 aggregateId: threadId,
812 occurredAt: now,
813 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
814 causationEventId: null,
815 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
816 metadata: {},
817 payload: {
818 threadId,
819 messageId: MessageId.makeUnsafe("message-keep"),
820 role: "assistant",
821 text: "Keep",
822 attachments: [
823 {
824 type: "image",
825 id: keepAttachmentId,
826 name: "keep.png",
827 mimeType: "image/png",
828 sizeBytes: 5,
829 },
830 ],
831 turnId: TurnId.makeUnsafe("turn-keep"),
832 streaming: false,
833 createdAt: now,
834 updatedAt: now,
837835 },
838 ],
839 turnId: TurnId.makeUnsafe("turn-keep"),
840 streaming: false,
841 createdAt: now,
842 updatedAt: now,
843 },
844 });
836 });
845837
846 yield* appendAndProject({
847 type: "thread.turn-diff-completed",
848 eventId: EventId.makeUnsafe("evt-revert-files-5"),
849 aggregateKind: "thread",
850 aggregateId: threadId,
851 occurredAt: now,
852 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
853 causationEventId: null,
854 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
855 metadata: {},
856 payload: {
857 threadId,
858 turnId: TurnId.makeUnsafe("turn-remove"),
859 checkpointTurnCount: 2,
860 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
861 status: "ready",
862 files: [],
863 assistantMessageId: MessageId.makeUnsafe("message-remove"),
864 completedAt: now,
865 },
866 });
838 yield* appendAndProject({
839 type: "thread.turn-diff-completed",
840 eventId: EventId.makeUnsafe("evt-revert-files-5"),
841 aggregateKind: "thread",
842 aggregateId: threadId,
843 occurredAt: now,
844 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
845 causationEventId: null,
846 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
847 metadata: {},
848 payload: {
849 threadId,
850 turnId: TurnId.makeUnsafe("turn-remove"),
851 checkpointTurnCount: 2,
852 checkpointRef: CheckpointRef.makeUnsafe(
853 "refs/t3/checkpoints/thread-revert-files/turn/2",
854 ),
855 status: "ready",
856 files: [],
857 assistantMessageId: MessageId.makeUnsafe("message-remove"),
858 completedAt: now,
859 },
860 });
867861
868 yield* appendAndProject({
869 type: "thread.message-sent",
870 eventId: EventId.makeUnsafe("evt-revert-files-6"),
871 aggregateKind: "thread",
872 aggregateId: threadId,
873 occurredAt: now,
874 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
875 causationEventId: null,
876 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
877 metadata: {},
878 payload: {
879 threadId,
880 messageId: MessageId.makeUnsafe("message-remove"),
881 role: "assistant",
882 text: "Remove",
883 attachments: [
884 {
885 type: "image",
886 id: removeAttachmentId,
887 name: "remove.png",
888 mimeType: "image/png",
889 sizeBytes: 5,
862 yield* appendAndProject({
863 type: "thread.message-sent",
864 eventId: EventId.makeUnsafe("evt-revert-files-6"),
865 aggregateKind: "thread",
866 aggregateId: threadId,
867 occurredAt: now,
868 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
869 causationEventId: null,
870 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
871 metadata: {},
872 payload: {
873 threadId,
874 messageId: MessageId.makeUnsafe("message-remove"),
875 role: "assistant",
876 text: "Remove",
877 attachments: [
878 {
879 type: "image",
880 id: removeAttachmentId,
881 name: "remove.png",
882 mimeType: "image/png",
883 sizeBytes: 5,
884 },
885 ],
886 turnId: TurnId.makeUnsafe("turn-remove"),
887 streaming: false,
888 createdAt: now,
889 updatedAt: now,
890890 },
891 ],
892 turnId: TurnId.makeUnsafe("turn-remove"),
893 streaming: false,
894 createdAt: now,
895 updatedAt: now,
896 },
897 });
891 });
898892
899 const keepPath = path.join(
900 stateDir,
901 "attachments",
902 `${keepAttachmentId}.png`,
903 );
904 const removePath = path.join(
905 stateDir,
906 "attachments",
907 `${removeAttachmentId}.png`,
908 );
909 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
910 fs.writeFileSync(keepPath, Buffer.from("keep"));
911 fs.writeFileSync(removePath, Buffer.from("remove"));
912 const otherThreadPath = path.join(stateDir, "attachments", `${otherThreadAttachmentId}.png`);
913 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
914 assert.equal(fs.existsSync(keepPath), true);
915 assert.equal(fs.existsSync(removePath), true);
916 assert.equal(fs.existsSync(otherThreadPath), true);
893 const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`);
894 const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`);
895 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
896 fs.writeFileSync(keepPath, Buffer.from("keep"));
897 fs.writeFileSync(removePath, Buffer.from("remove"));
898 const otherThreadPath = path.join(
899 stateDir,
900 "attachments",
901 `${otherThreadAttachmentId}.png`,
902 );
903 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
904 assert.equal(fs.existsSync(keepPath), true);
905 assert.equal(fs.existsSync(removePath), true);
906 assert.equal(fs.existsSync(otherThreadPath), true);
917907
918 yield* appendAndProject({
919 type: "thread.reverted",
920 eventId: EventId.makeUnsafe("evt-revert-files-7"),
921 aggregateKind: "thread",
922 aggregateId: threadId,
923 occurredAt: now,
924 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
925 causationEventId: null,
926 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
927 metadata: {},
928 payload: {
929 threadId,
930 turnCount: 1,
931 },
932 });
908 yield* appendAndProject({
909 type: "thread.reverted",
910 eventId: EventId.makeUnsafe("evt-revert-files-7"),
911 aggregateKind: "thread",
912 aggregateId: threadId,
913 occurredAt: now,
914 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
915 causationEventId: null,
916 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
917 metadata: {},
918 payload: {
919 threadId,
920 turnCount: 1,
921 },
922 });
933923
934924 assert.equal(fs.existsSync(keepPath), true);
935925 assert.equal(fs.existsSync(removePath), false);
......@@ -962,107 +952,107 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
962952 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
963953
964954 yield* appendAndProject({
965 type: "project.created",
966 eventId: EventId.makeUnsafe("evt-delete-files-1"),
967 aggregateKind: "project",
968 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
969 occurredAt: now,
970 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
971 causationEventId: null,
972 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
973 metadata: {},
974 payload: {
975 projectId: ProjectId.makeUnsafe("project-delete-files"),
976 title: "Project Delete Files",
977 workspaceRoot: "/tmp/project-delete-files",
978 defaultModel: null,
979 scripts: [],
980 createdAt: now,
981 updatedAt: now,
982 },
983 });
955 type: "project.created",
956 eventId: EventId.makeUnsafe("evt-delete-files-1"),
957 aggregateKind: "project",
958 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
959 occurredAt: now,
960 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
961 causationEventId: null,
962 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
963 metadata: {},
964 payload: {
965 projectId: ProjectId.makeUnsafe("project-delete-files"),
966 title: "Project Delete Files",
967 workspaceRoot: "/tmp/project-delete-files",
968 defaultModel: null,
969 scripts: [],
970 createdAt: now,
971 updatedAt: now,
972 },
973 });
984974
985 yield* appendAndProject({
986 type: "thread.created",
987 eventId: EventId.makeUnsafe("evt-delete-files-2"),
988 aggregateKind: "thread",
989 aggregateId: threadId,
990 occurredAt: now,
991 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
992 causationEventId: null,
993 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
994 metadata: {},
995 payload: {
996 threadId,
997 projectId: ProjectId.makeUnsafe("project-delete-files"),
998 title: "Thread Delete Files",
999 model: "gpt-5-codex",
1000 runtimeMode: "full-access",
1001 branch: null,
1002 worktreePath: null,
1003 createdAt: now,
1004 updatedAt: now,
1005 },
1006 });
975 yield* appendAndProject({
976 type: "thread.created",
977 eventId: EventId.makeUnsafe("evt-delete-files-2"),
978 aggregateKind: "thread",
979 aggregateId: threadId,
980 occurredAt: now,
981 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
982 causationEventId: null,
983 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
984 metadata: {},
985 payload: {
986 threadId,
987 projectId: ProjectId.makeUnsafe("project-delete-files"),
988 title: "Thread Delete Files",
989 model: "gpt-5-codex",
990 runtimeMode: "full-access",
991 branch: null,
992 worktreePath: null,
993 createdAt: now,
994 updatedAt: now,
995 },
996 });
1007997
1008 yield* appendAndProject({
1009 type: "thread.message-sent",
1010 eventId: EventId.makeUnsafe("evt-delete-files-3"),
1011 aggregateKind: "thread",
1012 aggregateId: threadId,
1013 occurredAt: now,
1014 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
1015 causationEventId: null,
1016 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1017 metadata: {},
1018 payload: {
1019 threadId,
1020 messageId: MessageId.makeUnsafe("message-delete-files"),
1021 role: "user",
1022 text: "Delete",
1023 attachments: [
1024 {
1025 type: "image",
1026 id: attachmentId,
1027 name: "delete.png",
1028 mimeType: "image/png",
1029 sizeBytes: 5,
998 yield* appendAndProject({
999 type: "thread.message-sent",
1000 eventId: EventId.makeUnsafe("evt-delete-files-3"),
1001 aggregateKind: "thread",
1002 aggregateId: threadId,
1003 occurredAt: now,
1004 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
1005 causationEventId: null,
1006 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1007 metadata: {},
1008 payload: {
1009 threadId,
1010 messageId: MessageId.makeUnsafe("message-delete-files"),
1011 role: "user",
1012 text: "Delete",
1013 attachments: [
1014 {
1015 type: "image",
1016 id: attachmentId,
1017 name: "delete.png",
1018 mimeType: "image/png",
1019 sizeBytes: 5,
1020 },
1021 ],
1022 turnId: null,
1023 streaming: false,
1024 createdAt: now,
1025 updatedAt: now,
10301026 },
1031 ],
1032 turnId: null,
1033 streaming: false,
1034 createdAt: now,
1035 updatedAt: now,
1036 },
1037 });
1027 });
10381028
1039 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1040 const otherThreadAttachmentPath = path.join(
1041 stateDir,
1042 "attachments",
1043 `${otherThreadAttachmentId}.png`,
1044 );
1045 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
1046 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1047 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));
1048 assert.equal(fs.existsSync(threadAttachmentPath), true);
1049 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
1029 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1030 const otherThreadAttachmentPath = path.join(
1031 stateDir,
1032 "attachments",
1033 `${otherThreadAttachmentId}.png`,
1034 );
1035 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
1036 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1037 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));
1038 assert.equal(fs.existsSync(threadAttachmentPath), true);
1039 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
10501040
1051 yield* appendAndProject({
1052 type: "thread.deleted",
1053 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1054 aggregateKind: "thread",
1055 aggregateId: threadId,
1056 occurredAt: now,
1057 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1058 causationEventId: null,
1059 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1060 metadata: {},
1061 payload: {
1062 threadId,
1063 deletedAt: now,
1064 },
1065 });
1041 yield* appendAndProject({
1042 type: "thread.deleted",
1043 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1044 aggregateKind: "thread",
1045 aggregateId: threadId,
1046 occurredAt: now,
1047 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1048 causationEventId: null,
1049 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1050 metadata: {},
1051 payload: {
1052 threadId,
1053 deletedAt: now,
1054 },
1055 });
10661056
10671057 assert.equal(fs.existsSync(threadAttachmentPath), false);
10681058 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
......@@ -1075,7 +1065,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10751065 );
10761066
10771067 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1078 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"))).pipe(
1068 Effect.sync(() =>
1069 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")),
1070 ).pipe(
10791071 Effect.flatMap((stateDir) =>
10801072 Effect.gen(function* () {
10811073 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-4
......@@ -78,9 +78,8 @@ interface AttachmentSideEffects {
7878}
7979
8080const materializeAttachmentsForProjection = Effect.fn(
81 (input: {
82 readonly attachments: ReadonlyArray<ChatAttachment>;
83 }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
81 (input: { readonly attachments: ReadonlyArray<ChatAttachment> }) =>
82 Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
8483);
8584
8685function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
......@@ -336,7 +335,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
336335 },
337336 { concurrency: 1 },
338337 );
339
340338});
341339
342340const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1-8
......@@ -1,11 +1,4 @@
1import {
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
1import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
92import { assert, it } from "@effect/vitest";
103import { Effect, Layer } from "effect";
114import * as SqlClient from "effect/unstable/sql/SqlClient";
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+20-6
......@@ -41,7 +41,10 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
4141const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
4242const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4343
44async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2000): Promise<void> {
44async function waitFor(
45 predicate: () => boolean | Promise<boolean>,
46 timeoutMs = 2000,
47): Promise<void> {
4548 const deadline = Date.now() + timeoutMs;
4649 const poll = async (): Promise<void> => {
4750 if (await predicate()) {
......@@ -101,7 +104,10 @@ describe("ProviderCommandReactor", () => {
101104 ? input.resumeCursor
102105 : undefined;
103106 const model =
104 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"
107 typeof input === "object" &&
108 input !== null &&
109 "model" in input &&
110 typeof input.model === "string"
105111 ? input.model
106112 : undefined;
107113 const threadId =
......@@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => {
474480
475481 await waitFor(async () => {
476482 const readModel = await Effect.runPromise(harness.engine.getReadModel());
477 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
483 const thread = readModel.threads.find(
484 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
485 );
478486 return thread?.runtimeMode === "approval-required";
479487 });
480488 await waitFor(() => harness.startSession.mock.calls.length === 2);
......@@ -563,7 +571,9 @@ describe("ProviderCommandReactor", () => {
563571
564572 await waitFor(async () => {
565573 const readModel = await Effect.runPromise(harness.engine.getReadModel());
566 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
574 const thread = readModel.threads.find(
575 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
576 );
567577 return thread?.runtimeMode === "approval-required";
568578 });
569579 await waitFor(() => harness.startSession.mock.calls.length === 2);
......@@ -767,9 +777,13 @@ describe("ProviderCommandReactor", () => {
767777
768778 await waitFor(async () => {
769779 const readModel = await Effect.runPromise(harness.engine.getReadModel());
770 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
780 const thread = readModel.threads.find(
781 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
782 );
771783 if (!thread) return false;
772 return thread.activities.some((activity) => activity.kind === "provider.approval.respond.failed");
784 return thread.activities.some(
785 (activity) => activity.kind === "provider.approval.respond.failed",
786 );
773787 });
774788
775789 const readModel = await Effect.runPromise(harness.engine.getReadModel());
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+23-17
......@@ -224,9 +224,9 @@ const make = Effect.gen(function* () {
224224 });
225225
226226 const resolveActiveSession = (threadId: ThreadId) =>
227 providerService.listSessions().pipe(
228 Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)),
229 );
227 providerService
228 .listSessions()
229 .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)));
230230
231231 const startProviderSession = (input?: {
232232 readonly resumeCursor?: unknown;
......@@ -234,13 +234,15 @@ const make = Effect.gen(function* () {
234234 }) =>
235235 providerService.startSession(threadId, {
236236 threadId,
237 ...(input?.provider ?? preferredProvider
237 ...((input?.provider ?? preferredProvider)
238238 ? { provider: input?.provider ?? preferredProvider }
239239 : {}),
240240 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
241241 ...(desiredModel ? { model: desiredModel } : {}),
242242 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
243 ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}),
243 ...(options?.providerOptions !== undefined
244 ? { providerOptions: options.providerOptions }
245 : {}),
244246 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
245247 runtimeMode: desiredRuntimeMode,
246248 });
......@@ -265,16 +267,15 @@ const make = Effect.gen(function* () {
265267 thread.session && thread.session.status !== "stopped" ? thread.id : null;
266268 if (existingSessionThreadId) {
267269 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
268 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
270 const providerChanged =
271 options?.provider !== undefined && options.provider !== currentProvider;
269272 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
270273 const sessionModelSwitch =
271274 currentProvider === undefined
272275 ? "in-session"
273276 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
274 const modelChanged =
275 options?.model !== undefined && options.model !== activeSession?.model;
276 const shouldRestartForModelChange =
277 modelChanged && sessionModelSwitch === "restart-session";
277 const modelChanged = options?.model !== undefined && options.model !== activeSession?.model;
278 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
278279
279280 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {
280281 return existingSessionThreadId;
......@@ -345,15 +346,16 @@ const make = Effect.gen(function* () {
345346 });
346347 const normalizedInput = toNonEmptyProviderInput(input.messageText);
347348 const normalizedAttachments = input.attachments ?? [];
348 const activeSession = yield* providerService.listSessions().pipe(
349 Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),
350 );
349 const activeSession = yield* providerService
350 .listSessions()
351 .pipe(
352 Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),
353 );
351354 const sessionModelSwitch =
352355 activeSession === undefined
353356 ? "in-session"
354357 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
355 const modelForTurn =
356 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
358 const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
357359
358360 yield* providerService.sendTurn({
359361 threadId: input.threadId,
......@@ -474,8 +476,12 @@ const make = Effect.gen(function* () {
474476 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
475477 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
476478 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
477 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),
478 ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}),
479 ...(event.payload.modelOptions !== undefined
480 ? { modelOptions: event.payload.modelOptions }
481 : {}),
482 ...(event.payload.providerOptions !== undefined
483 ? { providerOptions: event.payload.providerOptions }
484 : {}),
479485 interactionMode: event.payload.interactionMode,
480486 createdAt: event.payload.createdAt,
481487 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-3
......@@ -379,7 +379,9 @@ describe("ProviderRuntimeIngestion", () => {
379379
380380 await Effect.runPromise(Effect.sleep("40 millis"));
381381 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
382 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
382 const midThread = midReadModel.threads.find(
383 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
384 );
383385 expect(midThread?.session?.status).toBe("running");
384386 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");
385387
......@@ -620,7 +622,9 @@ describe("ProviderRuntimeIngestion", () => {
620622 const proposedPlan = thread.proposedPlans.find(
621623 (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final",
622624 );
623 expect(proposedPlan?.planMarkdown).toBe("## Ship plan\n\n- wire projection\n- render follow-up");
625 expect(proposedPlan?.planMarkdown).toBe(
626 "## Ship plan\n\n- wire projection\n- render follow-up",
627 );
624628 });
625629
626630 it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => {
......@@ -683,7 +687,8 @@ describe("ProviderRuntimeIngestion", () => {
683687 ),
684688 );
685689 const proposedPlan = thread.proposedPlans.find(
686 (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer",
690 (entry: ProviderRuntimeTestProposedPlan) =>
691 entry.id === "plan:thread-1:turn:turn-plan-buffer",
687692 );
688693 expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second");
689694 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+25-26
......@@ -210,9 +210,9 @@ function runtimeEventToActivities(
210210 ? "Command approval requested"
211211 : requestKind === "file-read"
212212 ? "File-read approval requested"
213 : requestKind === "file-change"
214 ? "File-change approval requested"
215 : "Approval requested",
213 : requestKind === "file-change"
214 ? "File-change approval requested"
215 : "Approval requested",
216216 payload: {
217217 requestId: toApprovalRequestId(event.requestId),
218218 ...(requestKind ? { requestKind } : {}),
......@@ -298,7 +298,9 @@ function runtimeEventToActivities(
298298 summary: "Plan updated",
299299 payload: {
300300 plan: event.payload.plan,
301 ...(event.payload.explanation !== undefined ? { explanation: event.payload.explanation } : {}),
301 ...(event.payload.explanation !== undefined
302 ? { explanation: event.payload.explanation }
303 : {}),
302304 },
303305 turnId: toTurnId(event.turnId) ?? null,
304306 ...maybeSequence,
......@@ -358,7 +360,9 @@ function runtimeEventToActivities(
358360 payload: {
359361 taskId: event.payload.taskId,
360362 ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}),
361 ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}),
363 ...(event.payload.description
364 ? { detail: truncateDetail(event.payload.description) }
365 : {}),
362366 },
363367 turnId: toTurnId(event.turnId) ?? null,
364368 ...maybeSequence,
......@@ -525,11 +529,7 @@ const make = Effect.gen(function* () {
525529 return isGitRepository(workspaceCwd);
526530 });
527531
528 const rememberAssistantMessageId = (
529 threadId: ThreadId,
530 turnId: TurnId,
531 messageId: MessageId,
532 ) =>
532 const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
533533 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
534534 Effect.flatMap((existingIds) =>
535535 Cache.set(
......@@ -547,11 +547,7 @@ const make = Effect.gen(function* () {
547547 ),
548548 );
549549
550 const forgetAssistantMessageId = (
551 threadId: ThreadId,
552 turnId: TurnId,
553 messageId: MessageId,
554 ) =>
550 const forgetAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
555551 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
556552 Effect.flatMap((existingIds) =>
557553 Option.match(existingIds, {
......@@ -616,7 +612,8 @@ const make = Effect.gen(function* () {
616612 const existing = Option.getOrUndefined(existingEntry);
617613 return Cache.set(bufferedProposedPlanById, planId, {
618614 text: `${existing?.text ?? ""}${delta}`,
619 createdAt: existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt,
615 createdAt:
616 existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt,
620617 });
621618 }),
622619 );
......@@ -633,7 +630,8 @@ const make = Effect.gen(function* () {
633630 const clearBufferedProposedPlan = (planId: string) =>
634631 Cache.invalidate(bufferedProposedPlanById, planId);
635632
636 const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId);
633 const clearAssistantMessageState = (messageId: MessageId) =>
634 clearBufferedAssistantText(messageId);
637635
638636 const finalizeAssistantMessage = (input: {
639637 event: ProviderRuntimeEvent;
......@@ -862,8 +860,8 @@ const make = Effect.gen(function* () {
862860 : event.type === "turn.completed" && runtimeTurnState(event) === "failed"
863861 ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
864862 : status === "ready"
865 ? null
866 : (thread.session?.lastError ?? null);
863 ? null
864 : (thread.session?.lastError ?? null);
867865
868866 if (shouldApplyThreadLifecycle) {
869867 yield* orchestrationEngine.dispatch({
......@@ -935,7 +933,9 @@ const make = Effect.gen(function* () {
935933 const assistantCompletion =
936934 event.type === "item.completed" && event.payload.itemType === "assistant_message"
937935 ? {
938 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),
936 messageId: MessageId.makeUnsafe(
937 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
938 ),
939939 fallbackText: event.payload.detail,
940940 }
941941 : undefined;
......@@ -951,10 +951,11 @@ const make = Effect.gen(function* () {
951951 if (assistantCompletion) {
952952 const assistantMessageId = assistantCompletion.messageId;
953953 const turnId = toTurnId(event.turnId);
954 const existingAssistantMessage = thread.messages.find((entry) => entry.id === assistantMessageId);
954 const existingAssistantMessage = thread.messages.find(
955 (entry) => entry.id === assistantMessageId,
956 );
955957 const shouldApplyFallbackCompletionText =
956 !existingAssistantMessage ||
957 existingAssistantMessage.text.length === 0;
958 !existingAssistantMessage || existingAssistantMessage.text.length === 0;
958959 if (turnId) {
959960 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
960961 }
......@@ -1029,9 +1030,7 @@ const make = Effect.gen(function* () {
10291030
10301031 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
10311032 ? true
1032 : activeTurnId === null ||
1033 eventTurnId === undefined ||
1034 sameId(activeTurnId, eventTurnId);
1033 : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);
10351034
10361035 if (shouldApplyRuntimeError) {
10371036 yield* orchestrationEngine.dispatch({
apps/server/src/orchestration/decider.ts+3-1
......@@ -302,7 +302,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
302302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303303 ...(command.model !== undefined ? { model: command.model } : {}),
304304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305 ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}),
305 ...(command.providerOptions !== undefined
306 ? { providerOptions: command.providerOptions }
307 : {}),
306308 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
307309 runtimeMode:
308310 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
apps/server/src/orchestration/projector.ts+1-6
......@@ -304,12 +304,7 @@ export function projectEvent(
304304 );
305305
306306 case "thread.runtime-mode-set":
307 return decodeForEvent(
308 ThreadRuntimeModeSetPayload,
309 event.payload,
310 event.type,
311 "payload",
312 ).pipe(
307 return decodeForEvent(ThreadRuntimeModeSetPayload, event.payload, event.type, "payload").pipe(
313308 Effect.map((payload) => ({
314309 ...nextBase,
315310 threads: updateThread(nextBase.threads, payload.threadId, {
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+1-3
......@@ -70,9 +70,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
7070
7171 const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) =>
7272 upsertProjectionThreadProposedPlanRow(row).pipe(
73 Effect.mapError(
74 toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"),
75 ),
73 Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")),
7674 );
7775
7876 const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) =>
apps/server/src/projectFaviconRoute.test.ts+9-5
......@@ -20,9 +20,7 @@ function makeTempDir(prefix: string): string {
2020 return dir;
2121}
2222
23async function withRouteServer(
24 run: (baseUrl: string) => Promise<void>,
25): Promise<void> {
23async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
2624 const server = http.createServer((req, res) => {
2725 const url = new URL(req.url ?? "/", "http://127.0.0.1");
2826 if (tryHandleProjectFaviconRequest(url, res)) {
......@@ -104,7 +102,10 @@ describe("tryHandleProjectFaviconRequest", () => {
104102 const projectDir = makeTempDir("t3code-favicon-route-source-");
105103 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
106104 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
107 fs.writeFileSync(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">');
105 fs.writeFileSync(
106 path.join(projectDir, "index.html"),
107 '<link rel="icon" href="/brand/logo.svg">',
108 );
108109 fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
109110
110111 await withRouteServer(async (baseUrl) => {
......@@ -120,7 +121,10 @@ describe("tryHandleProjectFaviconRequest", () => {
120121 const projectDir = makeTempDir("t3code-favicon-route-html-order-");
121122 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
122123 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
123 fs.writeFileSync(path.join(projectDir, "index.html"), '<link href="/brand/logo.svg" rel="icon">');
124 fs.writeFileSync(
125 path.join(projectDir, "index.html"),
126 '<link href="/brand/logo.svg" rel="icon">',
127 );
124128 fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
125129
126130 await withRouteServer(async (baseUrl) => {
apps/server/src/provider/Layers/CodexAdapter.ts+7-5
......@@ -403,7 +403,9 @@ function asRuntimeTaskId(taskId: string): RuntimeTaskId {
403403 return RuntimeTaskId.makeUnsafe(taskId);
404404}
405405
406function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
406function codexEventMessage(
407 payload: Record<string, unknown> | undefined,
408): Record<string, unknown> | undefined {
407409 return asObject(payload?.msg);
408410}
409411
......@@ -1037,7 +1039,9 @@ function mapToRuntimeEvents(
10371039 type: "content.delta",
10381040 payload: {
10391041 streamKind:
1040 asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text",
1042 asNumber(msg?.summary_index) !== undefined
1043 ? "reasoning_summary_text"
1044 : "reasoning_text",
10411045 delta,
10421046 ...(asNumber(msg?.summary_index) !== undefined
10431047 ? { summaryIndex: asNumber(msg?.summary_index) }
......@@ -1314,9 +1318,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13141318 detail: toMessage(cause, "Failed to start Codex adapter session."),
13151319 cause,
13161320 }),
1317 }).pipe(
1318 Effect.map((session) => session),
1319 );
1321 }).pipe(Effect.map((session) => session));
13201322 };
13211323
13221324 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-4
......@@ -29,10 +29,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
2929
3030const layer = it.layer(
3131 Layer.mergeAll(
32 Layer.provide(
33 ProviderAdapterRegistryLive,
34 Layer.succeed(CodexAdapter, fakeCodexAdapter),
35 ),
32 Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)),
3633 NodeServices.layer,
3734 ),
3835);
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-4
......@@ -23,10 +23,7 @@ export interface ProviderAdapterRegistryLiveOptions {
2323
2424const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>
2525 Effect.gen(function* () {
26 const adapters =
27 options?.adapters !== undefined
28 ? options.adapters
29 : [yield* CodexAdapter];
26 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];
3027 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3128
3229 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderHealth.test.ts+20-23
......@@ -132,30 +132,27 @@ it.effect("returns unauthenticated when auth probe reports login required", () =
132132 ),
133133);
134134
135it.effect(
136 "returns unauthenticated when login status output includes 'not logged in'",
137 () =>
138 Effect.gen(function* () {
139 const status = yield* checkCodexProviderStatus;
140 assert.strictEqual(status.provider, "codex");
141 assert.strictEqual(status.status, "error");
142 assert.strictEqual(status.available, true);
143 assert.strictEqual(status.authStatus, "unauthenticated");
144 assert.strictEqual(
145 status.message,
146 "Codex CLI is not authenticated. Run `codex login` and try again.",
147 );
148 }).pipe(
149 Effect.provide(
150 mockSpawnerLayer((args) => {
151 const joined = args.join(" ");
152 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
153 if (joined === "login status")
154 return { stdout: "Not logged in\n", stderr: "", code: 1 };
155 throw new Error(`Unexpected args: ${joined}`);
156 }),
157 ),
135it.effect("returns unauthenticated when login status output includes 'not logged in'", () =>
136 Effect.gen(function* () {
137 const status = yield* checkCodexProviderStatus;
138 assert.strictEqual(status.provider, "codex");
139 assert.strictEqual(status.status, "error");
140 assert.strictEqual(status.available, true);
141 assert.strictEqual(status.authStatus, "unauthenticated");
142 assert.strictEqual(
143 status.message,
144 "Codex CLI is not authenticated. Run `codex login` and try again.",
145 );
146 }).pipe(
147 Effect.provide(
148 mockSpawnerLayer((args) => {
149 const joined = args.join(" ");
150 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
151 if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 };
152 throw new Error(`Unexpected args: ${joined}`);
153 }),
158154 ),
155 ),
159156);
160157
161158it.effect("returns warning when login status command is unsupported", () =>
apps/server/src/provider/Layers/ProviderService.test.ts-1
......@@ -635,7 +635,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
635635 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
636636 }
637637 }
638
639638 }),
640639 );
641640});
apps/server/src/provider/Layers/ProviderService.ts+22-20
......@@ -141,9 +141,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
141141 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
142142 Effect.succeed(event).pipe(
143143 Effect.tap((canonicalEvent) =>
144 canonicalEventLogger
145 ? canonicalEventLogger.write(canonicalEvent, null)
146 : Effect.void,
144 canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void,
147145 ),
148146 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
149147 Effect.asVoid,
......@@ -193,7 +191,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
193191 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);
194192 if (hasActiveSession) {
195193 const activeSessions = yield* adapter.listSessions();
196 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);
194 const existing = activeSessions.find(
195 (session) => session.threadId === input.binding.threadId,
196 );
197197 if (existing) {
198198 yield* upsertSessionBinding(existing, input.binding.threadId);
199199 yield* analytics.record("provider.session.recovered", {
......@@ -292,7 +292,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
292292 }
293293
294294 yield* upsertSessionBinding(session, threadId, {
295 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
295 ...(input.providerOptions !== undefined
296 ? { providerOptions: input.providerOptions }
297 : {}),
296298 });
297299 yield* analytics.record("provider.session.started", {
298300 provider: session.provider,
......@@ -425,23 +427,23 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
425427
426428 const listSessions: ProviderServiceShape["listSessions"] = () =>
427429 Effect.gen(function* () {
428 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());
430 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) =>
431 adapter.listSessions(),
432 );
429433 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
430 const persistedBindings = yield* directory
431 .listThreadIds()
432 .pipe(
433 Effect.flatMap((threadIds) =>
434 Effect.forEach(
435 threadIds,
436 (threadId) =>
437 directory.getBinding(threadId).pipe(
438 Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>()),
439 ),
440 { concurrency: "unbounded" },
441 ),
434 const persistedBindings = yield* directory.listThreadIds().pipe(
435 Effect.flatMap((threadIds) =>
436 Effect.forEach(
437 threadIds,
438 (threadId) =>
439 directory
440 .getBinding(threadId)
441 .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
442 { concurrency: "unbounded" },
442443 ),
443 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
444 );
444 ),
445 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
446 );
445447 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();
446448 for (const bindingOption of persistedBindings) {
447449 const binding = Option.getOrUndefined(bindingOption);
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+4-5
......@@ -2,10 +2,7 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
22import { Effect, Layer, Option } from "effect";
33
44import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import {
6 ProviderSessionDirectoryPersistenceError,
7 ProviderValidationError,
8} from "../Errors.ts";
5import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts";
96import {
107 ProviderSessionDirectory,
118 type ProviderRuntimeBinding,
......@@ -138,7 +135,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
138135 const remove: ProviderSessionDirectoryShape["remove"] = (threadId) =>
139136 repository
140137 .deleteByThreadId({ threadId })
141 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")));
138 .pipe(
139 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")),
140 );
142141
143142 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
144143 repository.list().pipe(
apps/server/src/provider/Services/ProviderAdapter.ts+2-7
......@@ -66,10 +66,7 @@ export interface ProviderAdapterShape<TError> {
6666 /**
6767 * Interrupt an active turn.
6868 */
69 readonly interruptTurn: (
70 threadId: ThreadId,
71 turnId?: TurnId,
72 ) => Effect.Effect<void, TError>;
69 readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect<void, TError>;
7370
7471 /**
7572 * Respond to an interactive approval request.
......@@ -107,9 +104,7 @@ export interface ProviderAdapterShape<TError> {
107104 /**
108105 * Read a provider thread snapshot.
109106 */
110 readonly readThread: (
111 threadId: ThreadId,
112 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
107 readonly readThread: (threadId: ThreadId) => Effect.Effect<ProviderThreadSnapshot, TError>;
113108
114109 /**
115110 * Roll back a provider thread by N turns.
apps/server/src/wsServer.test.ts+16-16
......@@ -1236,21 +1236,19 @@ describe("WebSocket Server", () => {
12361236 return event.type === "thread.session-set";
12371237 });
12381238
1239 emitRuntimeEvent(
1240 {
1241 type: "content.delta",
1242 eventId: asEventId("evt-ws-runtime-message-delta"),
1243 provider: "codex",
1244 threadId: asThreadId("thread-1"),
1245 createdAt: new Date().toISOString(),
1246 turnId: asTurnId("turn-1"),
1247 itemId: asProviderItemId("item-1"),
1248 payload: {
1249 streamKind: "assistant_text",
1250 delta: "hello from runtime",
1251 },
1252 } as unknown as ProviderRuntimeEvent,
1253 );
1239 emitRuntimeEvent({
1240 type: "content.delta",
1241 eventId: asEventId("evt-ws-runtime-message-delta"),
1242 provider: "codex",
1243 threadId: asThreadId("thread-1"),
1244 createdAt: new Date().toISOString(),
1245 turnId: asTurnId("turn-1"),
1246 itemId: asProviderItemId("item-1"),
1247 payload: {
1248 streamKind: "assistant_text",
1249 delta: "hello from runtime",
1250 },
1251 } as unknown as ProviderRuntimeEvent);
12541252
12551253 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
12561254 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
......@@ -1568,7 +1566,9 @@ describe("WebSocket Server", () => {
15681566 });
15691567
15701568 expect(response.result).toBeUndefined();
1571 expect(response.error?.message).toContain("Workspace file path must stay within the project root.");
1569 expect(response.error?.message).toContain(
1570 "Workspace file path must stay within the project root.",
1571 );
15721572 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
15731573 });
15741574
apps/server/src/wsServer.ts+12-14
......@@ -198,8 +198,7 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
198198
199199function messageFromCause(cause: Cause.Cause<unknown>): string {
200200 const squashed = Cause.squash(cause);
201 const message =
202 squashed instanceof Error ? squashed.message.trim() : String(squashed).trim();
201 const message = squashed instanceof Error ? squashed.message.trim() : String(squashed).trim();
203202 return message.length > 0 ? message : Cause.pretty(cause);
204203}
205204
......@@ -330,10 +329,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
330329 } satisfies OrchestrationCommand;
331330 }
332331
333 if (
334 input.command.type === "project.meta.update" &&
335 input.command.workspaceRoot !== undefined
336 ) {
332 if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
337333 return {
338334 ...input.command,
339335 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
......@@ -793,14 +789,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
793789 relativePath: body.relativePath,
794790 path,
795791 });
796 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
797 Effect.mapError(
798 (cause) =>
799 new RouteRequestError({
800 message: `Failed to prepare workspace path: ${String(cause)}`,
801 }),
802 ),
803 );
792 yield* fileSystem
793 .makeDirectory(path.dirname(target.absolutePath), { recursive: true })
794 .pipe(
795 Effect.mapError(
796 (cause) =>
797 new RouteRequestError({
798 message: `Failed to prepare workspace path: ${String(cause)}`,
799 }),
800 ),
801 );
804802 yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe(
805803 Effect.mapError(
806804 (cause) =>
apps/web/src/appSettings.test.ts+2-12
......@@ -61,23 +61,13 @@ describe("resolveAppModelSelection", () => {
6161
6262describe("getSlashModelOptions", () => {
6363 it("includes saved custom model slugs for /model command suggestions", () => {
64 const options = getSlashModelOptions(
65 "codex",
66 ["custom/internal-model"],
67 "",
68 "gpt-5.3-codex",
69 );
64 const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex");
7065
7166 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
7267 });
7368
7469 it("filters slash-model suggestions across built-in and custom model names", () => {
75 const options = getSlashModelOptions(
76 "codex",
77 ["openai/gpt-oss-120b"],
78 "oss",
79 "gpt-5.3-codex",
80 );
70 const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex");
8171
8272 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
8373 });
apps/web/src/components/BranchToolbar.tsx+1-3
......@@ -130,9 +130,7 @@ export default function BranchToolbar({
130130 effectiveEnvMode={effectiveEnvMode}
131131 envLocked={envLocked}
132132 onSetThreadBranch={setThreadBranch}
133 {...(onCheckoutPullRequestRequest
134 ? { onCheckoutPullRequestRequest }
135 : {})}
133 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
136134 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
137135 />
138136 </div>
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-19
......@@ -113,27 +113,23 @@ export function BranchToolbarBranchSelector({
113113 const prReference = parsePullRequestReference(trimmedBranchQuery);
114114 const isSelectingWorktreeBase =
115115 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
116 const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest
117 ? `__checkout_pull_request__:${prReference}`
118 : null;
116 const checkoutPullRequestItemValue =
117 prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null;
119118 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
120119 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
121120 const createBranchItemValue = canCreateBranch
122121 ? `__create_new_branch__:${trimmedBranchQuery}`
123122 : null;
124 const branchPickerItems = useMemo(
125 () => {
126 const items = [...branchNames];
127 if (createBranchItemValue && !hasExactBranchMatch) {
128 items.push(createBranchItemValue);
129 }
130 if (checkoutPullRequestItemValue) {
131 items.unshift(checkoutPullRequestItemValue);
132 }
133 return items;
134 },
135 [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch],
136 );
123 const branchPickerItems = useMemo(() => {
124 const items = [...branchNames];
125 if (createBranchItemValue && !hasExactBranchMatch) {
126 items.push(createBranchItemValue);
127 }
128 if (checkoutPullRequestItemValue) {
129 items.unshift(checkoutPullRequestItemValue);
130 }
131 return items;
132 }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]);
137133 const filteredBranchPickerItems = useMemo(
138134 () =>
139135 normalizedDeferredBranchQuery.length === 0
......@@ -397,9 +393,7 @@ export function BranchToolbarBranchSelector({
397393 >
398394 <div className="flex w-full items-center justify-between gap-2">
399395 <span className="truncate">{itemValue}</span>
400 {badge && (
401 <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>
402 )}
396 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
403397 </div>
404398 </ComboboxItem>
405399 );
apps/web/src/components/ChatView.browser.tsx+18-8
......@@ -414,9 +414,9 @@ async function setViewport(viewport: ViewportSpec): Promise<void> {
414414async function waitForProductionStyles(): Promise<void> {
415415 await vi.waitFor(
416416 () => {
417 expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe(
418 "",
419 );
417 expect(
418 getComputedStyle(document.documentElement).getPropertyValue("--background").trim(),
419 ).not.toBe("");
420420 expect(getComputedStyle(document.body).marginTop).toBe("0px");
421421 },
422422 {
......@@ -454,7 +454,9 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
454454 );
455455}
456456
457async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise<HTMLButtonElement> {
457async function waitForInteractionModeButton(
458 expectedLabel: "Chat" | "Plan",
459): Promise<HTMLButtonElement> {
458460 return waitForElement(
459461 () =>
460462 Array.from(document.querySelectorAll("button")).find(
......@@ -697,7 +699,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
697699 });
698700
699701 try {
700 const measurements: Array<UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }> = [];
702 const measurements: Array<
703 UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }
704 > = [];
701705
702706 for (const viewport of TEXT_VIEWPORT_MATRIX) {
703707 await mounted.setViewport(viewport);
......@@ -714,7 +718,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
714718 measurements.push({ ...measurement, viewport, estimatedHeightPx });
715719 }
716720
717 expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3);
721 expect(
722 new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx)))
723 .size,
724 ).toBeGreaterThanOrEqual(3);
718725
719726 const byMeasuredWidth = measurements.toSorted(
720727 (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx,
......@@ -756,7 +763,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
756763 { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx },
757764 );
758765
759 const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;
766 const measuredDeltaPx =
767 mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;
760768 const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx;
761769 expect(measuredDeltaPx).toBeGreaterThan(0);
762770 expect(estimatedDeltaPx).toBeGreaterThan(0);
......@@ -844,7 +852,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
844852
845853 await vi.waitFor(
846854 () => {
847 const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor);
855 const openRequest = wsRequests.find(
856 (request) => request._tag === WS_METHODS.shellOpenInEditor,
857 );
848858 expect(openRequest).toMatchObject({
849859 _tag: WS_METHODS.shellOpenInEditor,
850860 cwd: "/repo/project",
apps/web/src/components/ChatView.tsx+21-19
......@@ -615,7 +615,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
615615 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
616616 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
617617 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
618 const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId);
618 const getDraftThreadByProjectId = useComposerDraftStore(
619 (store) => store.getDraftThreadByProjectId,
620 );
619621 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
620622 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
621623 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -657,9 +659,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
657659 const [nowTick, setNowTick] = useState(() => Date.now());
658660 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
659661 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
660 const [pullRequestDialogState, setPullRequestDialogState] = useState<PullRequestDialogState | null>(
661 null,
662 );
662 const [pullRequestDialogState, setPullRequestDialogState] =
663 useState<PullRequestDialogState | null>(null);
663664 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
664665 Record<string, string[]>
665666 >({});
......@@ -765,16 +766,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
765766 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
766767 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
767768
768 const openPullRequestDialog = useCallback((reference?: string) => {
769 if (!canCheckoutPullRequestIntoThread) {
770 return;
771 }
772 setPullRequestDialogState({
773 initialReference: reference ?? null,
774 key: Date.now(),
775 });
776 setComposerHighlightedItemId(null);
777 }, [canCheckoutPullRequestIntoThread]);
769 const openPullRequestDialog = useCallback(
770 (reference?: string) => {
771 if (!canCheckoutPullRequestIntoThread) {
772 return;
773 }
774 setPullRequestDialogState({
775 initialReference: reference ?? null,
776 key: Date.now(),
777 });
778 setComposerHighlightedItemId(null);
779 },
780 [canCheckoutPullRequestIntoThread],
781 );
778782
779783 const closePullRequestDialog = useCallback(() => {
780784 setPullRequestDialogState(null);
......@@ -4131,7 +4135,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
41314135 onPrepared={handlePreparedPullRequestThread}
41324136 />
41334137 ) : null}
4134 </div>{/* end chat column */}
4138 </div>
4139 {/* end chat column */}
41354140
41364141 {/* Plan sidebar */}
41374142 {planSidebarOpen ? (
......@@ -5744,10 +5749,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
57445749 }
57455750 >
57465751 <span
5747 className={cn(
5748 "flex min-w-0 items-center gap-2",
5749 props.compact ? "max-w-36" : undefined,
5750 )}
5752 className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)}
57515753 >
57525754 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
57535755 <span className="truncate">{selectedModelLabel}</span>
apps/web/src/components/ComposerPromptEditor.tsx+48-48
......@@ -1,7 +1,4 @@
1import {
2 LexicalComposer,
3 type InitialConfigType,
4} from "@lexical/react/LexicalComposer";
1import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer";
52import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
63import { ContentEditable } from "@lexical/react/LexicalContentEditable";
74import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
......@@ -331,12 +328,11 @@ function $setSelectionAtComposerOffset(nextOffset: number): void {
331328 const composerLength = $getComposerRootLength();
332329 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));
333330 const remainingRef = { value: boundedOffset };
334 const point =
335 findSelectionPointAtOffset(root, remainingRef) ?? {
336 key: root.getKey(),
337 offset: root.getChildren().length,
338 type: "element" as const,
339 };
331 const point = findSelectionPointAtOffset(root, remainingRef) ?? {
332 key: root.getKey(),
333 offset: root.getChildren().length,
334 type: "element" as const,
335 };
340336 const selection = $createRangeSelection();
341337 selection.anchor.set(point.key, point.offset, point.type);
342338 selection.focus.set(point.key, point.offset, point.type);
......@@ -724,7 +720,10 @@ function ComposerPromptEditorInner({
724720 editorState.read(() => {
725721 const nextValue = $getRoot().getTextContent();
726722 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
727 const nextCursor = clampCursor(nextValue, $readSelectionOffsetFromEditorState(fallbackCursor));
723 const nextCursor = clampCursor(
724 nextValue,
725 $readSelectionOffsetFromEditorState(fallbackCursor),
726 );
728727 const previousSnapshot = snapshotRef.current;
729728 if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {
730729 return;
......@@ -771,41 +770,42 @@ function ComposerPromptEditorInner({
771770 );
772771}
773772
774export const ComposerPromptEditor = forwardRef<ComposerPromptEditorHandle, ComposerPromptEditorProps>(
775 function ComposerPromptEditor(
776 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },
777 ref,
778 ) {
779 const initialValueRef = useRef(value);
780 const initialConfig = useMemo<InitialConfigType>(
781 () => ({
782 namespace: "t3tools-composer-editor",
783 editable: true,
784 nodes: [ComposerMentionNode],
785 editorState: () => {
786 $setComposerEditorPrompt(initialValueRef.current);
787 },
788 onError: (error) => {
789 throw error;
790 },
791 }),
792 [],
793 );
773export const ComposerPromptEditor = forwardRef<
774 ComposerPromptEditorHandle,
775 ComposerPromptEditorProps
776>(function ComposerPromptEditor(
777 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },
778 ref,
779) {
780 const initialValueRef = useRef(value);
781 const initialConfig = useMemo<InitialConfigType>(
782 () => ({
783 namespace: "t3tools-composer-editor",
784 editable: true,
785 nodes: [ComposerMentionNode],
786 editorState: () => {
787 $setComposerEditorPrompt(initialValueRef.current);
788 },
789 onError: (error) => {
790 throw error;
791 },
792 }),
793 [],
794 );
794795
795 return (
796 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
797 <ComposerPromptEditorInner
798 value={value}
799 cursor={cursor}
800 disabled={disabled}
801 placeholder={placeholder}
802 onChange={onChange}
803 onPaste={onPaste}
804 editorRef={ref}
805 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
806 {...(className ? { className } : {})}
807 />
808 </LexicalComposer>
809 );
810 },
811);
796 return (
797 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
798 <ComposerPromptEditorInner
799 value={value}
800 cursor={cursor}
801 disabled={disabled}
802 placeholder={placeholder}
803 onChange={onChange}
804 onPaste={onPaste}
805 editorRef={ref}
806 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
807 {...(className ? { className } : {})}
808 />
809 </LexicalComposer>
810 );
811});
apps/web/src/components/DiffPanel.tsx+8-1
......@@ -4,7 +4,14 @@ import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
55import { ThreadId, type TurnId } from "@t3tools/contracts";
66import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
7import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
7import {
8 type WheelEvent as ReactWheelEvent,
9 useCallback,
10 useEffect,
11 useMemo,
12 useRef,
13 useState,
14} from "react";
815import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
916import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
1017import { cn } from "~/lib/utils";
apps/web/src/components/GitActionsControl.logic.test.ts+2-8
......@@ -577,10 +577,7 @@ describe("when: branch has no upstream configured", () => {
577577 });
578578
579579 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
580 const items = buildMenuItems(
581 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
582 false,
583 );
580 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false);
584581 assert.deepEqual(items, [
585582 {
586583 id: "commit",
......@@ -627,10 +624,7 @@ describe("when: branch has no upstream configured", () => {
627624 });
628625
629626 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {
630 const items = buildMenuItems(
631 status({ hasUpstream: false, pr: null, aheadCount: 2 }),
632 false,
633 );
627 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false);
634628 assert.deepEqual(items, [
635629 {
636630 id: "commit",
apps/web/src/components/GitActionsControl.logic.ts+1-6
......@@ -123,12 +123,7 @@ export function buildMenuItems(
123123 const canCommit = !isBusy && hasChanges;
124124 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
125125 const canCreatePr =
126 !isBusy &&
127 hasBranch &&
128 !hasChanges &&
129 !hasOpenPr &&
130 gitStatus.aheadCount > 0 &&
131 !isBehind;
126 !isBusy && hasBranch && !hasChanges && !hasOpenPr && gitStatus.aheadCount > 0 && !isBehind;
132127 const canOpenPr = !isBusy && hasOpenPr;
133128
134129 return [
apps/web/src/components/GitActionsControl.tsx+17-11
......@@ -255,7 +255,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
255255 }) => {
256256 const actionStatus = statusOverride ?? gitStatusForActions;
257257 const actionBranch = actionStatus?.branch ?? null;
258 const actionIsDefaultBranch = isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);
258 const actionIsDefaultBranch =
259 isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);
259260 const includesCommit =
260261 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);
261262 if (
......@@ -331,7 +332,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
331332 stopProgressUpdates();
332333 const resultToast = summarizeGitResult(result);
333334
334 const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
335 const existingOpenPrUrl =
336 actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
335337 const prUrl = result.pr.url ?? existingOpenPrUrl;
336338 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
337339 const shouldOfferOpenPrCta =
......@@ -374,12 +376,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
374376 },
375377 }
376378 : shouldOfferOpenPrCta
377 ? {
378 actionProps: {
379 children: "View PR",
380 onClick: () => {
381 const api = readNativeApi();
382 if (!api) return;
379 ? {
380 actionProps: {
381 children: "View PR",
382 onClick: () => {
383 const api = readNativeApi();
384 if (!api) return;
383385 closeResultToast();
384386 void api.shell.openExternal(prUrl);
385387 },
......@@ -424,7 +426,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
424426
425427 const continuePendingDefaultBranchAction = useCallback(() => {
426428 if (!pendingDefaultBranchAction) return;
427 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;
429 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
430 pendingDefaultBranchAction;
428431 setPendingDefaultBranchAction(null);
429432 void runGitActionWithToast({
430433 action,
......@@ -453,7 +456,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
453456
454457 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
455458 if (!pendingDefaultBranchAction) return;
456 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;
459 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
460 pendingDefaultBranchAction;
457461 setPendingDefaultBranchAction(null);
458462 checkoutNewBranchAndRunAction({
459463 action,
......@@ -725,7 +729,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
725729 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
726730 <span className="text-muted-foreground">Branch</span>
727731 <span className="flex items-center justify-between gap-2">
728 <span className="font-medium">{gitStatusForActions?.branch ?? "(detached HEAD)"}</span>
732 <span className="font-medium">
733 {gitStatusForActions?.branch ?? "(detached HEAD)"}
734 </span>
729735 {isDefaultBranch && (
730736 <span className="text-right text-warning text-xs">Warning: default branch</span>
731737 )}
apps/web/src/components/Icons.tsx+13-52
......@@ -74,10 +74,7 @@ export const VisualStudioCode: Icon = (props) => {
7474 filterUnits="userSpaceOnUse"
7575 >
7676 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
77 <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
8178 <feOffset />
8279 <feGaussianBlur stdDeviation="4.167" />
8380 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
......@@ -94,10 +91,7 @@ export const VisualStudioCode: Icon = (props) => {
9491 filterUnits="userSpaceOnUse"
9592 >
9693 <feFlood floodOpacity="0" result="BackgroundImageFix" />
97 <feColorMatrix
98 in="SourceAlpha"
99 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
100 />
94 <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
10195 <feOffset />
10296 <feGaussianBlur stdDeviation="4.167" />
10397 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
......@@ -185,28 +179,16 @@ export const Gemini: Icon = (props) => (
185179 <ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" />
186180 </g>
187181 <g filter="url(#gemini__e)">
188 <path
189 fill="#FA4340"
190 d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z"
191 />
182 <path fill="#FA4340" d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z" />
192183 </g>
193184 <g filter="url(#gemini__f)">
194 <path
195 fill="#FA4340"
196 d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z"
197 />
185 <path fill="#FA4340" d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z" />
198186 </g>
199187 <g filter="url(#gemini__g)">
200 <path
201 fill="#14BB69"
202 d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z"
203 />
188 <path fill="#14BB69" d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z" />
204189 </g>
205190 <g filter="url(#gemini__h)">
206 <path
207 fill="#14BB69"
208 d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z"
209 />
191 <path fill="#14BB69" d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z" />
210192 </g>
211193 </g>
212194 <defs>
......@@ -221,10 +203,7 @@ export const Gemini: Icon = (props) => (
221203 >
222204 <feFlood floodOpacity="0" result="BackgroundImageFix" />
223205 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
224 <feGaussianBlur
225 result="effect1_foregroundBlur_69_17998"
226 stdDeviation="18"
227 />
206 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="18" />
228207 </filter>
229208 <filter
230209 id="gemini__c"
......@@ -237,10 +216,7 @@ export const Gemini: Icon = (props) => (
237216 >
238217 <feFlood floodOpacity="0" result="BackgroundImageFix" />
239218 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
240 <feGaussianBlur
241 result="effect1_foregroundBlur_69_17998"
242 stdDeviation="32"
243 />
219 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
244220 </filter>
245221 <filter
246222 id="gemini__d"
......@@ -253,10 +229,7 @@ export const Gemini: Icon = (props) => (
253229 >
254230 <feFlood floodOpacity="0" result="BackgroundImageFix" />
255231 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
256 <feGaussianBlur
257 result="effect1_foregroundBlur_69_17998"
258 stdDeviation="32"
259 />
232 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
260233 </filter>
261234 <filter
262235 id="gemini__e"
......@@ -269,10 +242,7 @@ export const Gemini: Icon = (props) => (
269242 >
270243 <feFlood floodOpacity="0" result="BackgroundImageFix" />
271244 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
272 <feGaussianBlur
273 result="effect1_foregroundBlur_69_17998"
274 stdDeviation="32"
275 />
245 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
276246 </filter>
277247 <filter
278248 id="gemini__f"
......@@ -285,10 +255,7 @@ export const Gemini: Icon = (props) => (
285255 >
286256 <feFlood floodOpacity="0" result="BackgroundImageFix" />
287257 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
288 <feGaussianBlur
289 result="effect1_foregroundBlur_69_17998"
290 stdDeviation="32"
291 />
258 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
292259 </filter>
293260 <filter
294261 id="gemini__g"
......@@ -301,10 +268,7 @@ export const Gemini: Icon = (props) => (
301268 >
302269 <feFlood floodOpacity="0" result="BackgroundImageFix" />
303270 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
304 <feGaussianBlur
305 result="effect1_foregroundBlur_69_17998"
306 stdDeviation="32"
307 />
271 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
308272 </filter>
309273 <filter
310274 id="gemini__h"
......@@ -317,10 +281,7 @@ export const Gemini: Icon = (props) => (
317281 >
318282 <feFlood floodOpacity="0" result="BackgroundImageFix" />
319283 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
320 <feGaussianBlur
321 result="effect1_foregroundBlur_69_17998"
322 stdDeviation="32"
323 />
284 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
324285 </filter>
325286 </defs>
326287 </svg>
apps/web/src/components/PlanSidebar.tsx+3-8
......@@ -106,8 +106,7 @@ const PlanSidebar = memo(function PlanSidebar({
106106 toastManager.add({
107107 type: "error",
108108 title: "Could not save plan",
109 description:
110 error instanceof Error ? error.message : "An error occurred.",
109 description: error instanceof Error ? error.message : "An error occurred.",
111110 });
112111 })
113112 .then(
......@@ -199,9 +198,7 @@ const PlanSidebar = memo(function PlanSidebar({
199198 step.status === "completed" && "bg-emerald-500/5",
200199 )}
201200 >
202 <div className="mt-0.5">
203 {stepStatusIcon(step.status)}
204 </div>
201 <div className="mt-0.5">{stepStatusIcon(step.status)}</div>
205202 <p
206203 className={cn(
207204 "text-[13px] leading-snug",
......@@ -251,9 +248,7 @@ const PlanSidebar = memo(function PlanSidebar({
251248 {/* Empty state */}
252249 {!activePlan && !planMarkdown ? (
253250 <div className="flex flex-col items-center justify-center py-12 text-center">
254 <p className="text-[13px] text-muted-foreground/40">
255 No active plan yet.
256 </p>
251 <p className="text-[13px] text-muted-foreground/40">No active plan yet.</p>
257252 <p className="mt-1 text-[11px] text-muted-foreground/30">
258253 Plans will appear here when generated.
259254 </p>
apps/web/src/components/PullRequestThreadDialog.tsx+48-46
......@@ -116,56 +116,58 @@ export function PullRequestThreadDialog({
116116 }
117117 }, [resolvedPullRequest?.state]);
118118
119 const handleConfirm = useCallback(async (mode: "local" | "worktree") => {
120 if (!parsedReference) {
121 setReferenceDirty(true);
122 return;
123 }
124 if (!parsedReference || !resolvedPullRequest || !cwd) {
125 return;
126 }
127 setPreparingMode(mode);
128 try {
129 const result = await preparePullRequestThreadMutation.mutateAsync({
130 reference: parsedReference,
131 mode,
132 });
133 await onPrepared({
134 branch: result.branch,
135 worktreePath: result.worktreePath,
136 });
137 onOpenChange(false);
138 } finally {
139 setPreparingMode(null);
140 }
141 }, [
142 cwd,
143 onOpenChange,
144 onPrepared,
145 parsedReference,
146 preparePullRequestThreadMutation,
147 resolvedPullRequest,
148 ]);
119 const handleConfirm = useCallback(
120 async (mode: "local" | "worktree") => {
121 if (!parsedReference) {
122 setReferenceDirty(true);
123 return;
124 }
125 if (!parsedReference || !resolvedPullRequest || !cwd) {
126 return;
127 }
128 setPreparingMode(mode);
129 try {
130 const result = await preparePullRequestThreadMutation.mutateAsync({
131 reference: parsedReference,
132 mode,
133 });
134 await onPrepared({
135 branch: result.branch,
136 worktreePath: result.worktreePath,
137 });
138 onOpenChange(false);
139 } finally {
140 setPreparingMode(null);
141 }
142 },
143 [
144 cwd,
145 onOpenChange,
146 onPrepared,
147 parsedReference,
148 preparePullRequestThreadMutation,
149 resolvedPullRequest,
150 ],
151 );
149152
150 const validationMessage =
151 !referenceDirty
152 ? null
153 : reference.trim().length === 0
154 ? "Paste a GitHub pull request URL or enter 123 / #123."
155 : parsedReference === null
156 ? "Use a GitHub pull request URL, 123, or #123."
157 : null;
153 const validationMessage = !referenceDirty
154 ? null
155 : reference.trim().length === 0
156 ? "Paste a GitHub pull request URL or enter 123 / #123."
157 : parsedReference === null
158 ? "Use a GitHub pull request URL, 123, or #123."
159 : null;
158160 const errorMessage =
159161 validationMessage ??
160162 (resolvedPullRequest === null && resolvePullRequestQuery.isError
161 ? (resolvePullRequestQuery.error instanceof Error
162 ? resolvePullRequestQuery.error.message
163 : "Failed to resolve pull request.")
164 : (preparePullRequestThreadMutation.error instanceof Error
165 ? preparePullRequestThreadMutation.error.message
166 : preparePullRequestThreadMutation.error
167 ? "Failed to prepare pull request thread."
168 : null));
163 ? resolvePullRequestQuery.error instanceof Error
164 ? resolvePullRequestQuery.error.message
165 : "Failed to resolve pull request."
166 : preparePullRequestThreadMutation.error instanceof Error
167 ? preparePullRequestThreadMutation.error.message
168 : preparePullRequestThreadMutation.error
169 ? "Failed to prepare pull request thread."
170 : null);
169171
170172 return (
171173 <Dialog
apps/web/src/components/Sidebar.logic.ts+1-2
......@@ -1,8 +1,7 @@
11import type { Thread } from "../types";
22import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
33
4export const THREAD_SELECTION_SAFE_SELECTOR =
5 "[data-thread-item], [data-thread-selection-safe]";
4export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
65
76export interface ThreadStatusPill {
87 label:
apps/web/src/components/Sidebar.tsx+226-186
......@@ -83,10 +83,7 @@ import {
8383import { useThreadSelectionStore } from "../threadSelectionStore";
8484import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
8585import { isNonEmpty as isNonEmptyString } from "effect/String";
86import {
87 resolveThreadStatusPill,
88 shouldClearThreadSelectionOnMouseDown,
89} from "./Sidebar.logic";
86import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic";
9087
9188const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
9289const THREAD_PREVIEW_LIMIT = 6;
......@@ -897,7 +894,14 @@ export default function Sidebar() {
897894 params: { threadId },
898895 });
899896 },
900 [clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection],
897 [
898 clearSelection,
899 navigate,
900 rangeSelectTo,
901 selectedThreadIds.size,
902 setSelectionAnchor,
903 toggleThreadSelection,
904 ],
901905 );
902906
903907 const handleProjectContextMenu = useCallback(
......@@ -1077,7 +1081,16 @@ export default function Sidebar() {
10771081 window.removeEventListener("keydown", onWindowKeyDown);
10781082 window.removeEventListener("mousedown", onMouseDown);
10791083 };
1080 }, [clearSelection, getDraftThread, handleNewThread, keybindings, projects, routeThreadId, selectedThreadIds.size, threads]);
1084 }, [
1085 clearSelection,
1086 getDraftThread,
1087 handleNewThread,
1088 keybindings,
1089 projects,
1090 routeThreadId,
1091 selectedThreadIds.size,
1092 threads,
1093 ]);
10811094
10821095 useEffect(() => {
10831096 if (!isElectron) return;
......@@ -1403,7 +1416,8 @@ export default function Sidebar() {
14031416 const projectThreads = threads
14041417 .filter((thread) => thread.projectId === project.id)
14051418 .toSorted((a, b) => {
1406 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
1419 const byDate =
1420 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
14071421 if (byDate !== 0) return byDate;
14081422 return b.id.localeCompare(a.id);
14091423 });
......@@ -1418,10 +1432,7 @@ export default function Sidebar() {
14181432 return (
14191433 <SortableProjectItem key={project.id} projectId={project.id}>
14201434 {(dragHandleProps) => (
1421 <Collapsible
1422 className="group/collapsible"
1423 open={project.expanded}
1424 >
1435 <Collapsible className="group/collapsible" open={project.expanded}>
14251436 <div className="group/project-header relative">
14261437 <SidebarMenuButton
14271438 size="sm"
......@@ -1481,193 +1492,222 @@ export default function Sidebar() {
14811492
14821493 <CollapsibleContent keepMounted>
14831494 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0">
1484 {visibleThreads.map((thread) => {
1485 const isActive = routeThreadId === thread.id;
1486 const isSelected = selectedThreadIds.has(thread.id);
1487 const isHighlighted = isActive || isSelected;
1488 const threadStatus = resolveThreadStatusPill({
1489 thread,
1490 hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,
1491 hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true,
1492 });
1493 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1494 const terminalStatus = terminalStatusFromRunningIds(
1495 selectThreadTerminalState(terminalStateByThreadId, thread.id)
1496 .runningTerminalIds,
1497 );
1498
1499 return (
1500 <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
1501 <SidebarMenuSubButton
1502 render={<div role="button" tabIndex={0} />}
1503 size="sm"
1504 isActive={isActive}
1505 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${
1506 isSelected
1507 ? "bg-primary/15 text-foreground dark:bg-primary/10"
1508 : isActive
1509 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1510 : "text-muted-foreground"
1511 }`}
1512 onClick={(event) => {
1513 handleThreadClick(event, thread.id, orderedProjectThreadIds);
1514 }}
1515 onKeyDown={(event) => {
1516 if (event.key !== "Enter" && event.key !== " ") return;
1517 event.preventDefault();
1495 {visibleThreads.map((thread) => {
1496 const isActive = routeThreadId === thread.id;
1497 const isSelected = selectedThreadIds.has(thread.id);
1498 const isHighlighted = isActive || isSelected;
1499 const threadStatus = resolveThreadStatusPill({
1500 thread,
1501 hasPendingApprovals:
1502 pendingApprovalByThreadId.get(thread.id) === true,
1503 hasPendingUserInput:
1504 pendingUserInputByThreadId.get(thread.id) === true,
1505 });
1506 const prStatus = prStatusIndicator(
1507 prByThreadId.get(thread.id) ?? null,
1508 );
1509 const terminalStatus = terminalStatusFromRunningIds(
1510 selectThreadTerminalState(terminalStateByThreadId, thread.id)
1511 .runningTerminalIds,
1512 );
1513
1514 return (
1515 <SidebarMenuSubItem
1516 key={thread.id}
1517 className="w-full"
1518 data-thread-item
1519 >
1520 <SidebarMenuSubButton
1521 render={<div role="button" tabIndex={0} />}
1522 size="sm"
1523 isActive={isActive}
1524 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${
1525 isSelected
1526 ? "bg-primary/15 text-foreground dark:bg-primary/10"
1527 : isActive
1528 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1529 : "text-muted-foreground"
1530 }`}
1531 onClick={(event) => {
1532 handleThreadClick(
1533 event,
1534 thread.id,
1535 orderedProjectThreadIds,
1536 );
1537 }}
1538 onKeyDown={(event) => {
1539 if (event.key !== "Enter" && event.key !== " ") return;
1540 event.preventDefault();
1541 if (selectedThreadIds.size > 0) {
1542 clearSelection();
1543 }
1544 setSelectionAnchor(thread.id);
1545 void navigate({
1546 to: "/$threadId",
1547 params: { threadId: thread.id },
1548 });
1549 }}
1550 onContextMenu={(event) => {
1551 event.preventDefault();
1552 if (
1553 selectedThreadIds.size > 0 &&
1554 selectedThreadIds.has(thread.id)
1555 ) {
1556 void handleMultiSelectContextMenu({
1557 x: event.clientX,
1558 y: event.clientY,
1559 });
1560 } else {
15181561 if (selectedThreadIds.size > 0) {
15191562 clearSelection();
15201563 }
1521 setSelectionAnchor(thread.id);
1522 void navigate({
1523 to: "/$threadId",
1524 params: { threadId: thread.id },
1564 void handleThreadContextMenu(thread.id, {
1565 x: event.clientX,
1566 y: event.clientY,
15251567 });
1526 }}
1527 onContextMenu={(event) => {
1528 event.preventDefault();
1529 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1530 void handleMultiSelectContextMenu({
1531 x: event.clientX,
1532 y: event.clientY,
1533 });
1534 } else {
1535 if (selectedThreadIds.size > 0) {
1536 clearSelection();
1537 }
1538 void handleThreadContextMenu(thread.id, {
1539 x: event.clientX,
1540 y: event.clientY,
1541 });
1542 }
1543 }}
1544 >
1545 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1546 {prStatus && (
1547 <Tooltip>
1548 <TooltipTrigger
1549 render={
1550 <button
1551 type="button"
1552 aria-label={prStatus.tooltip}
1553 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1554 onClick={(event) => {
1555 openPrLink(event, prStatus.url);
1556 }}
1557 >
1558 <GitPullRequestIcon className="size-3" />
1559 </button>
1560 }
1561 />
1562 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1563 </Tooltip>
1564 )}
1565 {threadStatus && (
1566 <span
1567 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1568 >
1569 <span
1570 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1571 threadStatus.pulse ? "animate-pulse" : ""
1572 }`}
1573 />
1574 <span className="hidden md:inline">{threadStatus.label}</span>
1575 </span>
1576 )}
1577 {renamingThreadId === thread.id ? (
1578 <input
1579 ref={(el) => {
1580 if (el && renamingInputRef.current !== el) {
1581 renamingInputRef.current = el;
1582 el.focus();
1583 el.select();
1584 }
1585 }}
1586 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1587 value={renamingTitle}
1588 onChange={(e) => setRenamingTitle(e.target.value)}
1589 onKeyDown={(e) => {
1590 e.stopPropagation();
1591 if (e.key === "Enter") {
1592 e.preventDefault();
1593 renamingCommittedRef.current = true;
1594 void commitRename(thread.id, renamingTitle, thread.title);
1595 } else if (e.key === "Escape") {
1596 e.preventDefault();
1597 renamingCommittedRef.current = true;
1598 cancelRename();
1599 }
1600 }}
1601 onBlur={() => {
1602 if (!renamingCommittedRef.current) {
1603 void commitRename(thread.id, renamingTitle, thread.title);
1604 }
1605 }}
1606 onClick={(e) => e.stopPropagation()}
1568 }
1569 }}
1570 >
1571 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1572 {prStatus && (
1573 <Tooltip>
1574 <TooltipTrigger
1575 render={
1576 <button
1577 type="button"
1578 aria-label={prStatus.tooltip}
1579 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1580 onClick={(event) => {
1581 openPrLink(event, prStatus.url);
1582 }}
1583 >
1584 <GitPullRequestIcon className="size-3" />
1585 </button>
1586 }
16071587 />
1608 ) : (
1609 <span className="min-w-0 flex-1 truncate text-xs">
1610 {thread.title}
1611 </span>
1612 )}
1613 </div>
1614 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1615 {terminalStatus && (
1588 <TooltipPopup side="top">
1589 {prStatus.tooltip}
1590 </TooltipPopup>
1591 </Tooltip>
1592 )}
1593 {threadStatus && (
1594 <span
1595 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1596 >
16161597 <span
1617 role="img"
1618 aria-label={terminalStatus.label}
1619 title={terminalStatus.label}
1620 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1621 >
1622 <TerminalIcon
1623 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1624 />
1598 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1599 threadStatus.pulse ? "animate-pulse" : ""
1600 }`}
1601 />
1602 <span className="hidden md:inline">
1603 {threadStatus.label}
16251604 </span>
1626 )}
1605 </span>
1606 )}
1607 {renamingThreadId === thread.id ? (
1608 <input
1609 ref={(el) => {
1610 if (el && renamingInputRef.current !== el) {
1611 renamingInputRef.current = el;
1612 el.focus();
1613 el.select();
1614 }
1615 }}
1616 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1617 value={renamingTitle}
1618 onChange={(e) => setRenamingTitle(e.target.value)}
1619 onKeyDown={(e) => {
1620 e.stopPropagation();
1621 if (e.key === "Enter") {
1622 e.preventDefault();
1623 renamingCommittedRef.current = true;
1624 void commitRename(
1625 thread.id,
1626 renamingTitle,
1627 thread.title,
1628 );
1629 } else if (e.key === "Escape") {
1630 e.preventDefault();
1631 renamingCommittedRef.current = true;
1632 cancelRename();
1633 }
1634 }}
1635 onBlur={() => {
1636 if (!renamingCommittedRef.current) {
1637 void commitRename(
1638 thread.id,
1639 renamingTitle,
1640 thread.title,
1641 );
1642 }
1643 }}
1644 onClick={(e) => e.stopPropagation()}
1645 />
1646 ) : (
1647 <span className="min-w-0 flex-1 truncate text-xs">
1648 {thread.title}
1649 </span>
1650 )}
1651 </div>
1652 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1653 {terminalStatus && (
16271654 <span
1628 className={`text-[10px] ${
1629 isHighlighted ? "text-foreground/65" : "text-muted-foreground/40"
1630 }`}
1655 role="img"
1656 aria-label={terminalStatus.label}
1657 title={terminalStatus.label}
1658 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
16311659 >
1632 {formatRelativeTime(thread.createdAt)}
1660 <TerminalIcon
1661 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1662 />
16331663 </span>
1634 </div>
1635 </SidebarMenuSubButton>
1636 </SidebarMenuSubItem>
1637 );
1638 })}
1639
1640 {hasHiddenThreads && !isThreadListExpanded && (
1641 <SidebarMenuSubItem className="w-full">
1642 <SidebarMenuSubButton
1643 render={<button type="button" />}
1644 data-thread-selection-safe
1645 size="sm"
1646 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1647 onClick={() => {
1648 expandThreadListForProject(project.id);
1649 }}
1650 >
1651 <span>Show more</span>
1664 )}
1665 <span
1666 className={`text-[10px] ${
1667 isHighlighted
1668 ? "text-foreground/65"
1669 : "text-muted-foreground/40"
1670 }`}
1671 >
1672 {formatRelativeTime(thread.createdAt)}
1673 </span>
1674 </div>
16521675 </SidebarMenuSubButton>
16531676 </SidebarMenuSubItem>
1654 )}
1655 {hasHiddenThreads && isThreadListExpanded && (
1656 <SidebarMenuSubItem className="w-full">
1657 <SidebarMenuSubButton
1658 render={<button type="button" />}
1659 data-thread-selection-safe
1660 size="sm"
1661 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1662 onClick={() => {
1663 collapseThreadListForProject(project.id);
1664 }}
1665 >
1666 <span>Show less</span>
1667 </SidebarMenuSubButton>
1668 </SidebarMenuSubItem>
1669 )}
1670 </SidebarMenuSub>
1677 );
1678 })}
1679
1680 {hasHiddenThreads && !isThreadListExpanded && (
1681 <SidebarMenuSubItem className="w-full">
1682 <SidebarMenuSubButton
1683 render={<button type="button" />}
1684 data-thread-selection-safe
1685 size="sm"
1686 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1687 onClick={() => {
1688 expandThreadListForProject(project.id);
1689 }}
1690 >
1691 <span>Show more</span>
1692 </SidebarMenuSubButton>
1693 </SidebarMenuSubItem>
1694 )}
1695 {hasHiddenThreads && isThreadListExpanded && (
1696 <SidebarMenuSubItem className="w-full">
1697 <SidebarMenuSubButton
1698 render={<button type="button" />}
1699 data-thread-selection-safe
1700 size="sm"
1701 className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80"
1702 onClick={() => {
1703 collapseThreadListForProject(project.id);
1704 }}
1705 >
1706 <span>Show less</span>
1707 </SidebarMenuSubButton>
1708 </SidebarMenuSubItem>
1709 )}
1710 </SidebarMenuSub>
16711711 </CollapsibleContent>
16721712 </Collapsible>
16731713 )}
apps/web/src/components/composerFooterLayout.test.ts+1-3
......@@ -17,9 +17,7 @@ describe("shouldUseCompactComposerFooter", () => {
1717
1818 it("stays expanded at and above the breakpoint", () => {
1919 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false);
20 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(
21 false,
22 );
20 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false);
2321 });
2422
2523 it("uses a higher breakpoint for wide action states", () => {
apps/web/src/components/desktopUpdate.logic.test.ts+5-1
......@@ -31,7 +31,11 @@ const baseState: DesktopUpdateState = {
3131
3232describe("desktop update button state", () => {
3333 it("shows a download action when an update is available", () => {
34 const state: DesktopUpdateState = { ...baseState, status: "available", availableVersion: "1.1.0" };
34 const state: DesktopUpdateState = {
35 ...baseState,
36 status: "available",
37 availableVersion: "1.1.0",
38 };
3539 expect(shouldShowDesktopUpdateButton(state)).toBe(true);
3640 expect(resolveDesktopUpdateButtonAction(state)).toBe("download");
3741 });
apps/web/src/components/ui/scroll-area.tsx+2-1
......@@ -24,7 +24,8 @@ function ScrollArea({
2424 scrollFade &&
2525 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
2626 scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
27 hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
27 hideScrollbars &&
28 "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
2829 )}
2930 data-slot="scroll-area-viewport"
3031 >
apps/web/src/components/ui/toast.logic.test.ts+10-2
......@@ -17,7 +17,11 @@ describe("shouldHideCollapsedToastContent", () => {
1717
1818describe("buildVisibleToastLayout", () => {
1919 it("computes indices and offsets from the visible subset", () => {
20 const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }];
20 const visibleToasts = [
21 { id: "a", height: 48 },
22 { id: "b", height: 72 },
23 { id: "c", height: 24 },
24 ];
2125
2226 const layout = buildVisibleToastLayout(visibleToasts);
2327
......@@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => {
3741 });
3842
3943 it("treats missing heights as zero", () => {
40 const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]);
44 const layout = buildVisibleToastLayout([
45 { id: "a" },
46 { id: "b", height: undefined },
47 { id: "c", height: 30 },
48 ]);
4149
4250 assert.equal(layout.frontmostHeight, 0);
4351 assert.deepEqual(
apps/web/src/composer-logic.ts+7-10
......@@ -10,11 +10,7 @@ export interface ComposerTrigger {
1010 rangeEnd: number;
1111}
1212
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [
14 "model",
15 "plan",
16 "default",
17];
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
1814
1915function clampCursor(text: string, cursor: number): number {
2016 if (!Number.isFinite(cursor)) return text.length;
......@@ -65,7 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
6561 return expandedCursor;
6662}
6763
68function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number {
64function collapsedSegmentLength(
65 segment: { type: "text"; text: string } | { type: "mention" },
66): number {
6967 return segment.type === "mention" ? 1 : segment.text.length;
7068}
7169
......@@ -164,10 +162,9 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
164162 };
165163}
166164
167export function parseStandaloneComposerSlashCommand(text: string): Exclude<
168 ComposerSlashCommand,
169 "model"
170> | null {
165export function parseStandaloneComposerSlashCommand(
166 text: string,
167): Exclude<ComposerSlashCommand, "model"> | null {
171168 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
172169 if (!match) {
173170 return null;
apps/web/src/composerDraftStore.test.ts+6-4
......@@ -252,9 +252,9 @@ describe("composerDraftStore project draft thread mapping", () => {
252252 store.clearProjectDraftThreadId(projectId);
253253
254254 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
255 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe(
256 threadId,
257 );
255 expect(
256 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
257 ).toBe(threadId);
258258 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
259259 });
260260
......@@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => {
380380
381381 store.setModel(threadId, "gpt-5.3-codex");
382382
383 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");
383 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe(
384 "gpt-5.3-codex",
385 );
384386 });
385387});
386388
apps/web/src/composerDraftStore.ts+8-11
......@@ -9,11 +9,7 @@ import {
99 type RuntimeMode,
1010} from "@t3tools/contracts";
1111import { normalizeModelSlug } from "@t3tools/shared/model";
12import {
13 DEFAULT_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,
15 type ChatImageAttachment,
16} from "./types";
12import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
1713import { Debouncer } from "@tanstack/react-pacer";
1814import { create } from "zustand";
1915import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
......@@ -495,8 +491,7 @@ function hydreatePersistedComposerImageAttachment(
495491 attachment: PersistedComposerImageAttachment,
496492): File | null {
497493 const commaIndex = attachment.dataUrl.indexOf(",");
498 const header =
499 commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
494 const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
500495 const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1);
501496 if (payload.length === 0) {
502497 return null;
......@@ -605,7 +600,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
605600 const nextDraftThread: DraftThreadState = {
606601 projectId,
607602 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
608 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
603 runtimeMode:
604 options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
609605 interactionMode:
610606 options?.interactionMode ??
611607 existingThread?.interactionMode ??
......@@ -673,7 +669,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
673669 return state;
674670 }
675671 const nextWorktreePath =
676 options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null);
672 options.worktreePath === undefined
673 ? existing.worktreePath
674 : (options.worktreePath ?? null);
677675 const nextDraftThread: DraftThreadState = {
678676 projectId: nextProjectId,
679677 createdAt:
......@@ -685,8 +683,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
685683 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
686684 worktreePath: nextWorktreePath,
687685 envMode:
688 options.envMode ??
689 (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
686 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
690687 };
691688 const isUnchanged =
692689 nextDraftThread.projectId === existing.projectId &&
apps/web/src/lib/gitReactQuery.ts+1-7
......@@ -196,13 +196,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
196196 queryClient: QueryClient;
197197}) {
198198 return mutationOptions({
199 mutationFn: async ({
200 reference,
201 mode,
202 }: {
203 reference: string;
204 mode: "local" | "worktree";
205 }) => {
199 mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => {
206200 const api = ensureNativeApi();
207201 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
208202 return api.git.preparePullRequestThread({
apps/web/src/lib/turnDiffTree.test.ts+5-2
......@@ -91,7 +91,9 @@ describe("buildTurnDiffTree", () => {
9191 });
9292
9393 it("normalizes file paths with windows separators", () => {
94 const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]);
94 const tree = buildTurnDiffTree([
95 { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 },
96 ]);
9597
9698 expect(tree).toEqual([
9799 {
......@@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => {
157159
158160 expect(tree).toHaveLength(2);
159161 const directoryNodes = tree.filter(
160 (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => node.kind === "directory",
162 (node): node is Extract<(typeof tree)[number], { kind: "directory" }> =>
163 node.kind === "directory",
161164 );
162165 expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]);
163166 expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]);
apps/web/src/pendingUserInput.ts+1-3
......@@ -95,9 +95,7 @@ export function derivePendingUserInputProgress(
9595 questionIndex: number,
9696): PendingUserInputProgress {
9797 const normalizedQuestionIndex =
98 questions.length === 0
99 ? 0
100 : Math.max(0, Math.min(questionIndex, questions.length - 1));
98 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
10199 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
102100 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
103101 const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft);
apps/web/src/proposedPlan.test.ts+6-3
......@@ -31,9 +31,12 @@ describe("buildPlanImplementationPrompt", () => {
3131describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
3232 it("drops the redundant title heading and preserves the following markdown lines", () => {
3333 expect(
34 buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2", {
35 maxLines: 4,
36 }),
34 buildCollapsedProposedPlanPreviewMarkdown(
35 "# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2",
36 {
37 maxLines: 4,
38 },
39 ),
3740 ).toBe("- step 1\n- step 2");
3841 });
3942
apps/web/src/proposedPlan.ts+2-6
......@@ -5,8 +5,7 @@ export function proposedPlanTitle(planMarkdown: string): string | null {
55
66export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
77 const lines = planMarkdown.trimEnd().split(/\r?\n/);
8 const sourceLines =
9 lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
8 const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
109 while (sourceLines[0]?.trim().length === 0) {
1110 sourceLines.shift();
1211 }
......@@ -75,10 +74,7 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string {
7574 return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`;
7675}
7776
78export function resolvePlanFollowUpSubmission(input: {
79 draftText: string;
80 planMarkdown: string;
81}): {
77export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): {
8278 text: string;
8379 interactionMode: "default" | "plan";
8480} {
apps/web/src/pullRequestReference.test.ts+3-3
......@@ -4,9 +4,9 @@ import { parsePullRequestReference } from "./pullRequestReference";
44
55describe("parsePullRequestReference", () => {
66 it("accepts GitHub pull request URLs", () => {
7 expect(
8 parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42"),
9 ).toBe("https://github.com/pingdotgg/t3code/pull/42");
7 expect(parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42")).toBe(
8 "https://github.com/pingdotgg/t3code/pull/42",
9 );
1010 });
1111
1212 it("accepts raw numbers", () => {
apps/web/src/routes/_chat.$threadId.tsx+6-6
......@@ -92,7 +92,9 @@ const DiffPanelInlineSidebar = (props: {
9292 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
9393 );
9494 const formRect = composerForm.getBoundingClientRect();
95 const composerFooter = composerForm.querySelector<HTMLElement>("[data-chat-composer-footer='true']");
95 const composerFooter = composerForm.querySelector<HTMLElement>(
96 "[data-chat-composer-footer='true']",
97 );
9698 const composerRightActions = composerForm.querySelector<HTMLElement>(
9799 "[data-chat-composer-actions='right']",
98100 );
......@@ -103,9 +105,7 @@ const DiffPanelInlineSidebar = (props: {
103105 0
104106 : 0;
105107 const minimumComposerWidth =
106 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX +
107 composerRightActionsWidth +
108 composerFooterGap;
108 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
109109 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
110110 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
111111 const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
......@@ -156,8 +156,8 @@ function ChatThreadRouteView() {
156156 });
157157 const search = Route.useSearch();
158158 const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId));
159 const draftThreadExists = useComposerDraftStore(
160 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
159 const draftThreadExists = useComposerDraftStore((store) =>
160 Object.hasOwn(store.draftThreadsByThreadId, threadId),
161161 );
162162 const routeThreadExists = threadExists || draftThreadExists;
163163 const diffOpen = search.diff === "1";
apps/web/src/routes/_chat.settings.tsx+50-43
......@@ -115,54 +115,62 @@ function SettingsRouteView() {
115115 });
116116 }, [keybindingsConfigPath]);
117117
118 const addCustomModel = useCallback((provider: ProviderKind) => {
119 const customModelInput = customModelInputByProvider[provider];
120 const customModels = getCustomModelsForProvider(settings, provider);
121 const normalized = normalizeModelSlug(customModelInput, provider);
122 if (!normalized) {
123 setCustomModelErrorByProvider((existing) => ({
124 ...existing,
125 [provider]: "Enter a model slug.",
126 }));
127 return;
128 }
129 if (getModelOptions(provider).some((option) => option.slug === normalized)) {
130 setCustomModelErrorByProvider((existing) => ({
131 ...existing,
132 [provider]: "That model is already built in.",
133 }));
134 return;
135 }
136 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
137 setCustomModelErrorByProvider((existing) => ({
118 const addCustomModel = useCallback(
119 (provider: ProviderKind) => {
120 const customModelInput = customModelInputByProvider[provider];
121 const customModels = getCustomModelsForProvider(settings, provider);
122 const normalized = normalizeModelSlug(customModelInput, provider);
123 if (!normalized) {
124 setCustomModelErrorByProvider((existing) => ({
125 ...existing,
126 [provider]: "Enter a model slug.",
127 }));
128 return;
129 }
130 if (getModelOptions(provider).some((option) => option.slug === normalized)) {
131 setCustomModelErrorByProvider((existing) => ({
132 ...existing,
133 [provider]: "That model is already built in.",
134 }));
135 return;
136 }
137 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
138 setCustomModelErrorByProvider((existing) => ({
139 ...existing,
140 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
141 }));
142 return;
143 }
144 if (customModels.includes(normalized)) {
145 setCustomModelErrorByProvider((existing) => ({
146 ...existing,
147 [provider]: "That custom model is already saved.",
148 }));
149 return;
150 }
151
152 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
153 setCustomModelInputByProvider((existing) => ({
138154 ...existing,
139 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
155 [provider]: "",
140156 }));
141 return;
142 }
143 if (customModels.includes(normalized)) {
144157 setCustomModelErrorByProvider((existing) => ({
145158 ...existing,
146 [provider]: "That custom model is already saved.",
159 [provider]: null,
147160 }));
148 return;
149 }
150
151 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
152 setCustomModelInputByProvider((existing) => ({
153 ...existing,
154 [provider]: "",
155 }));
156 setCustomModelErrorByProvider((existing) => ({
157 ...existing,
158 [provider]: null,
159 }));
160 }, [customModelInputByProvider, settings, updateSettings]);
161 },
162 [customModelInputByProvider, settings, updateSettings],
163 );
161164
162165 const removeCustomModel = useCallback(
163166 (provider: ProviderKind, slug: string) => {
164167 const customModels = getCustomModelsForProvider(settings, provider);
165 updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug)));
168 updateSettings(
169 patchCustomModels(
170 provider,
171 customModels.filter((model) => model !== slug),
172 ),
173 );
166174 setCustomModelErrorByProvider((existing) => ({
167175 ...existing,
168176 [provider]: null,
......@@ -381,10 +389,9 @@ function SettingsRouteView() {
381389 variant="outline"
382390 onClick={() =>
383391 updateSettings(
384 patchCustomModels(
385 provider,
386 [...getDefaultCustomModelsForProvider(defaults, provider)],
387 ),
392 patchCustomModels(provider, [
393 ...getDefaultCustomModelsForProvider(defaults, provider),
394 ]),
388395 )
389396 }
390397 >
apps/web/src/session-logic.ts+13-14
......@@ -8,7 +8,13 @@ import {
88 type TurnId,
99} from "@t3tools/contracts";
1010
11import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
11import type {
12 ChatMessage,
13 ProposedPlan,
14 SessionPhase,
15 ThreadSession,
16 TurnDiffSummary,
17} from "./types";
1218
1319export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";
1420
......@@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt(
138144 return sendStartedAt;
139145}
140146
141function requestKindFromRequestType(
142 requestType: unknown,
143): PendingApproval["requestKind"] | null {
147function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
144148 switch (requestType) {
145149 case "command_execution_approval":
146150 case "exec_command_approval":
......@@ -333,9 +337,7 @@ export function deriveActivePlanState(
333337 return null;
334338 }
335339 const status =
336 record.status === "completed" || record.status === "inProgress"
337 ? record.status
338 : "pending";
340 record.status === "completed" || record.status === "inProgress" ? record.status : "pending";
339341 return {
340342 step: record.step,
341343 status,
......@@ -355,7 +357,9 @@ export function deriveActivePlanState(
355357 return {
356358 createdAt: latest.createdAt,
357359 turnId: latest.turnId,
358 ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}),
360 ...(payload && "explanation" in payload
361 ? { explanation: payload.explanation as string | null }
362 : {}),
359363 steps,
360364 };
361365}
......@@ -487,12 +491,7 @@ function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
487491 target.push(normalized);
488492}
489493
490function collectChangedFiles(
491 value: unknown,
492 target: string[],
493 seen: Set<string>,
494 depth: number,
495) {
494function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) {
496495 if (depth > 4 || target.length >= 12) {
497496 return;
498497 }
apps/web/src/store.ts+5-2
......@@ -124,7 +124,9 @@ function mapProjectsFromReadModel(
124124 const previousById = new Map(previous.map((project) => [project.id, project] as const));
125125 const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));
126126 const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
127 const previousOrderByCwd = new Map(previous.map((project, index) => [project.cwd, index] as const));
127 const previousOrderByCwd = new Map(
128 previous.map((project, index) => [project.cwd, index] as const),
129 );
128130 const persistedOrderByCwd = new Map(
129131 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
130132 );
......@@ -150,7 +152,8 @@ function mapProjectsFromReadModel(
150152
151153 return mappedProjects
152154 .map((project, incomingIndex) => {
153 const previousIndex = previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
155 const previousIndex =
156 previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
154157 const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;
155158 const orderIndex =
156159 previousIndex ??
apps/web/src/terminalStateStore.test.ts+28-9
......@@ -14,7 +14,10 @@ describe("terminalStateStore actions", () => {
1414 });
1515
1616 it("returns a closed default terminal state for unknown threads", () => {
17 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
17 const terminalState = selectThreadTerminalState(
18 useTerminalStateStore.getState().terminalStateByThreadId,
19 THREAD_ID,
20 );
1821 expect(terminalState).toEqual({
1922 terminalOpen: false,
2023 terminalHeight: 280,
......@@ -31,7 +34,10 @@ describe("terminalStateStore actions", () => {
3134 store.setTerminalOpen(THREAD_ID, true);
3235 store.splitTerminal(THREAD_ID, "terminal-2");
3336
34 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
37 const terminalState = selectThreadTerminalState(
38 useTerminalStateStore.getState().terminalStateByThreadId,
39 THREAD_ID,
40 );
3541 expect(terminalState.terminalOpen).toBe(true);
3642 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
3743 expect(terminalState.activeTerminalId).toBe("terminal-2");
......@@ -43,7 +49,10 @@ describe("terminalStateStore actions", () => {
4349 it("creates new terminals in a separate group", () => {
4450 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
4551
46 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
52 const terminalState = selectThreadTerminalState(
53 useTerminalStateStore.getState().terminalStateByThreadId,
54 THREAD_ID,
55 );
4756 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
4857 expect(terminalState.activeTerminalId).toBe("terminal-2");
4958 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
......@@ -57,12 +66,16 @@ describe("terminalStateStore actions", () => {
5766 const store = useTerminalStateStore.getState();
5867 store.splitTerminal(THREAD_ID, "terminal-2");
5968 store.setTerminalActivity(THREAD_ID, "terminal-2", true);
60 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([
61 "terminal-2",
62 ]);
69 expect(
70 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
71 .runningTerminalIds,
72 ).toEqual(["terminal-2"]);
6373
6474 store.setTerminalActivity(THREAD_ID, "terminal-2", false);
65 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([]);
75 expect(
76 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
77 .runningTerminalIds,
78 ).toEqual([]);
6679 });
6780
6881 it("resets to default and clears persisted entry when closing the last terminal", () => {
......@@ -70,7 +83,10 @@ describe("terminalStateStore actions", () => {
7083 store.closeTerminal(THREAD_ID, "default");
7184
7285 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();
73 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).terminalIds).toEqual(["default"]);
86 expect(
87 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
88 .terminalIds,
89 ).toEqual(["default"]);
7490 });
7591
7692 it("keeps a valid active terminal after closing an active split terminal", () => {
......@@ -79,7 +95,10 @@ describe("terminalStateStore actions", () => {
7995 store.splitTerminal(THREAD_ID, "terminal-3");
8096 store.closeTerminal(THREAD_ID, "terminal-3");
8197
82 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);
98 const terminalState = selectThreadTerminalState(
99 useTerminalStateStore.getState().terminalStateByThreadId,
100 THREAD_ID,
101 );
83102 expect(terminalState.activeTerminalId).toBe("terminal-2");
84103 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
85104 expect(terminalState.terminalGroups).toEqual([
apps/web/src/terminalStateStore.ts+28-10
......@@ -35,7 +35,10 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {
3535 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
3636}
3737
38function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] {
38function normalizeRunningTerminalIds(
39 runningTerminalIds: string[],
40 terminalIds: string[],
41): string[] {
3942 if (runningTerminalIds.length === 0) return [];
4043 const validTerminalIdSet = new Set(terminalIds);
4144 return [...new Set(runningTerminalIds)]
......@@ -58,7 +61,10 @@ function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string
5861 return candidate;
5962}
6063
61function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {
64function findGroupIndexByTerminalId(
65 terminalGroups: ThreadTerminalGroup[],
66 terminalId: string,
67): number {
6268 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
6369}
6470
......@@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina
183189 ? state.activeTerminalId
184190 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
185191 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
186 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
192 const activeGroupIdFromState = terminalGroups.some(
193 (group) => group.id === state.activeTerminalGroupId,
194 )
187195 ? state.activeTerminalGroupId
188196 : null;
189197 const activeGroupIdFromTerminal =
......@@ -239,14 +247,16 @@ function upsertTerminalIntoGroups(
239247 return normalized;
240248 }
241249
242 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;
250 const terminalIds = isNewTerminal
251 ? [...normalized.terminalIds, terminalId]
252 : normalized.terminalIds;
243253 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
244254
245255 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
246256 if (existingGroupIndex >= 0) {
247 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
248 (id) => id !== terminalId,
249 );
257 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
258 existingGroupIndex
259 ]!.terminalIds.filter((id) => id !== terminalId);
250260 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
251261 terminalGroups.splice(existingGroupIndex, 1);
252262 }
......@@ -266,13 +276,18 @@ function upsertTerminalIntoGroups(
266276 });
267277 }
268278
269 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
279 let activeGroupIndex = terminalGroups.findIndex(
280 (group) => group.id === normalized.activeTerminalGroupId,
281 );
270282 if (activeGroupIndex < 0) {
271283 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
272284 }
273285 if (activeGroupIndex < 0) {
274286 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
275 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);
287 const nextGroupId = assignUniqueGroupId(
288 fallbackGroupId(normalized.activeTerminalId),
289 usedGroupIds,
290 );
276291 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
277292 activeGroupIndex = terminalGroups.length - 1;
278293 }
......@@ -323,7 +338,10 @@ function newThreadTerminal(state: ThreadTerminalState, terminalId: string): Thre
323338 return upsertTerminalIntoGroups(state, terminalId, "new");
324339}
325340
326function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
341function setThreadActiveTerminal(
342 state: ThreadTerminalState,
343 terminalId: string,
344): ThreadTerminalState {
327345 const normalized = normalizeThreadTerminalState(state);
328346 if (!normalized.terminalIds.includes(terminalId)) {
329347 return normalized;
apps/web/src/wsNativeApi.test.ts+11-10
......@@ -12,12 +12,13 @@ import {
1212import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1313
1414const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
15const showContextMenuFallbackMock = vi.fn<
16 <T extends string>(
17 items: readonly ContextMenuItem<T>[],
18 position?: { x: number; y: number },
19 ) => Promise<T | null>
20>();
15const showContextMenuFallbackMock =
16 vi.fn<
17 <T extends string>(
18 items: readonly ContextMenuItem<T>[],
19 position?: { x: number; y: number },
20 ) => Promise<T | null>
21 >();
2122const channelListeners = new Map<string, Set<(data: unknown) => void>>();
2223const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(
2324 (channel, listener) => {
......@@ -400,10 +401,10 @@ describe("wsNativeApi", () => {
400401
401402 const { createWsNativeApi } = await import("./wsNativeApi");
402403 const api = createWsNativeApi();
403 await api.contextMenu.show(
404 [{ id: "delete", label: "Delete", destructive: true }],
405 { x: 20, y: 30 },
406 );
404 await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], {
405 x: 20,
406 y: 30,
407 });
407408
408409 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
409410 [{ id: "delete", label: "Delete", destructive: true }],
package.json+1
......@@ -38,6 +38,7 @@
3838 "test": "turbo run test",
3939 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
4040 "fmt": "oxfmt",
41 "fmt:check": "oxfmt --check",
4142 "build:contracts": "turbo run build --filter=@t3tools/contracts",
4243 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",
4344 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
packages/contracts/src/git.test.ts+3-1
......@@ -8,7 +8,9 @@ import {
88} from "./git";
99
1010const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);
11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput);
11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
12 GitPreparePullRequestThreadInput,
13);
1214const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1315
1416describe("GitCreateWorktreeInput", () => {
packages/contracts/src/git.ts+1-5
......@@ -14,11 +14,7 @@ const GitPushStepStatus = Schema.Literals([
1414 "skipped_up_to_date",
1515]);
1616const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);
17const GitPrStepStatus = Schema.Literals([
18 "created",
19 "opened_existing",
20 "skipped_not_requested",
21]);
17const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]);
2218const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
2319const GitPullRequestReference = TrimmedNonEmptyStringSchema;
2420const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
packages/contracts/src/orchestration.ts+1-3
......@@ -266,9 +266,7 @@ export const OrchestrationThread = Schema.Struct({
266266 updatedAt: IsoDateTime,
267267 deletedAt: Schema.NullOr(IsoDateTime),
268268 messages: Schema.Array(OrchestrationMessage),
269 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
270 Schema.withDecodingDefault(() => []),
271 ),
269 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])),
272270 activities: Schema.Array(OrchestrationThreadActivity),
273271 checkpoints: Schema.Array(OrchestrationCheckpointSummary),
274272 session: Schema.NullOr(OrchestrationSession),
packages/contracts/src/project.ts+1-3
......@@ -28,9 +28,7 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2828
2929export const ProjectWriteFileInput = Schema.Struct({
3030 cwd: TrimmedNonEmptyString,
31 relativePath: TrimmedNonEmptyString.check(
32 Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH),
33 ),
31 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
3432 contents: Schema.String,
3533});
3634export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;
packages/contracts/src/providerRuntime.ts+16-19
......@@ -61,12 +61,7 @@ const RuntimeThreadState = Schema.Literals([
6161]);
6262export type RuntimeThreadState = typeof RuntimeThreadState.Type;
6363
64const RuntimeTurnState = Schema.Literals([
65 "completed",
66 "failed",
67 "interrupted",
68 "cancelled",
69]);
64const RuntimeTurnState = Schema.Literals(["completed", "failed", "interrupted", "cancelled"]);
7065export type RuntimeTurnState = typeof RuntimeTurnState.Type;
7166
7267const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]);
......@@ -573,14 +568,16 @@ const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({
573568 type: SessionConfiguredType,
574569 payload: SessionConfiguredPayload,
575570});
576export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type;
571export type ProviderRuntimeSessionConfiguredEvent =
572 typeof ProviderRuntimeSessionConfiguredEvent.Type;
577573
578574const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({
579575 ...ProviderRuntimeEventBase.fields,
580576 type: SessionStateChangedType,
581577 payload: SessionStateChangedPayload,
582578});
583export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type;
579export type ProviderRuntimeSessionStateChangedEvent =
580 typeof ProviderRuntimeSessionStateChangedEvent.Type;
584581
585582const ProviderRuntimeSessionExitedEvent = Schema.Struct({
586583 ...ProviderRuntimeEventBase.fields,
......@@ -601,14 +598,16 @@ const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({
601598 type: ThreadStateChangedType,
602599 payload: ThreadStateChangedPayload,
603600});
604export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type;
601export type ProviderRuntimeThreadStateChangedEvent =
602 typeof ProviderRuntimeThreadStateChangedEvent.Type;
605603
606604const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({
607605 ...ProviderRuntimeEventBase.fields,
608606 type: ThreadMetadataUpdatedType,
609607 payload: ThreadMetadataUpdatedPayload,
610608});
611export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type;
609export type ProviderRuntimeThreadMetadataUpdatedEvent =
610 typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type;
612611
613612const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({
614613 ...ProviderRuntimeEventBase.fields,
......@@ -764,7 +763,8 @@ const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({
764763 type: UserInputResolvedType,
765764 payload: UserInputResolvedPayload,
766765});
767export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type;
766export type ProviderRuntimeUserInputResolvedEvent =
767 typeof ProviderRuntimeUserInputResolvedEvent.Type;
768768
769769const ProviderRuntimeTaskStartedEvent = Schema.Struct({
770770 ...ProviderRuntimeEventBase.fields,
......@@ -856,7 +856,8 @@ const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({
856856 type: McpOauthCompletedType,
857857 payload: McpOauthCompletedPayload,
858858});
859export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type;
859export type ProviderRuntimeMcpOauthCompletedEvent =
860 typeof ProviderRuntimeMcpOauthCompletedEvent.Type;
860861
861862const ProviderRuntimeModelReroutedEvent = Schema.Struct({
862863 ...ProviderRuntimeEventBase.fields,
......@@ -877,7 +878,8 @@ const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({
877878 type: DeprecationNoticeType,
878879 payload: DeprecationNoticePayload,
879880});
880export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type;
881export type ProviderRuntimeDeprecationNoticeEvent =
882 typeof ProviderRuntimeDeprecationNoticeEvent.Type;
881883
882884const ProviderRuntimeFilesPersistedEvent = Schema.Struct({
883885 ...ProviderRuntimeEventBase.fields,
......@@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent
969971export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;
970972
971973// Legacy helper aliases retained for adapters/tests.
972const ProviderRuntimeToolKind = Schema.Literals([
973 "command",
974 "file-read",
975 "file-change",
976 "other",
977]);
974const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]);
978975export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
979976
980977export const ProviderRuntimeTurnStatus = RuntimeTurnState;
packages/shared/src/shell.test.ts+3-1
......@@ -5,7 +5,9 @@ import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
55describe("extractPathFromShellOutput", () => {
66 it("extracts the path between capture markers", () => {
77 expect(
8 extractPathFromShellOutput("__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n"),
8 extractPathFromShellOutput(
9 "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n",
10 ),
911 ).toBe("/opt/homebrew/bin:/usr/bin");
1012 });
1113
scripts/build-desktop-artifact.ts+4-2
......@@ -23,8 +23,10 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
2323const RepoRoot = Effect.service(Path.Path).pipe(
2424 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
2525);
26const ProductionMacIconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>
27 path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
26const ProductionMacIconSource = Effect.zipWith(
27 RepoRoot,
28 Effect.service(Path.Path),
29 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
2830);
2931const ProductionLinuxIconSource = Effect.zipWith(
3032 RepoRoot,
scripts/dev-runner.ts+1-3
......@@ -399,9 +399,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
399399
400400 const envOverrides = {
401401 noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"),
402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv(
403 "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD",
404 ),
402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD"),
405403 logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"),
406404 };
407405
scripts/release-smoke.ts+29-8
......@@ -82,7 +82,12 @@ try {
8282
8383 execFileSync(
8484 process.execPath,
85 [resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot],
85 [
86 resolve(repoRoot, "scripts/update-release-package-versions.ts"),
87 "9.9.9-smoke.0",
88 "--root",
89 tempRoot,
90 ],
8691 {
8792 cwd: repoRoot,
8893 stdio: "inherit",
......@@ -95,17 +100,33 @@ try {
95100 });
96101
97102 const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8");
98 assertContains(lockfile, `"version": "9.9.9-smoke.0"`, "Expected bun.lock to contain the smoke version.");
103 assertContains(
104 lockfile,
105 `"version": "9.9.9-smoke.0"`,
106 "Expected bun.lock to contain the smoke version.",
107 );
99108
100109 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
101 execFileSync(process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], {
102 cwd: repoRoot,
103 stdio: "inherit",
104 });
110 execFileSync(
111 process.execPath,
112 [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path],
113 {
114 cwd: repoRoot,
115 stdio: "inherit",
116 },
117 );
105118
106119 const mergedManifest = readFileSync(arm64Path, "utf8");
107 assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", "Merged manifest is missing the arm64 asset.");
108 assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset.");
120 assertContains(
121 mergedManifest,
122 "T3-Code-9.9.9-smoke.0-arm64.zip",
123 "Merged manifest is missing the arm64 asset.",
124 );
125 assertContains(
126 mergedManifest,
127 "T3-Code-9.9.9-smoke.0-x64.zip",
128 "Merged manifest is missing the x64 asset.",
129 );
109130
110131 console.log("Release smoke checks passed.");
111132} finally {
scripts/update-release-package-versions.ts+2-1
......@@ -88,7 +88,8 @@ function parseArgs(argv: ReadonlyArray<string>): {
8888 return { version, rootDir, writeGithubOutput };
8989}
9090
91const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
91const isMain =
92 process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
9293
9394if (isMain) {
9495 const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2));