| author | |
| committer | |
| log | 223c3dc8005b583b19c502320f5de929958a7efd |
| tree | f2dfa92ecf3dcc712e6e43821e286003f7576b54 |
| parent | afb6e89bfdd9164c142162597bb865540b1909d2 |
| signature | Signed by PGP key B5690EEEBB952194 |
* 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: |
| 8 | 8 | |
| 9 | 9 | jobs: |
| 10 | 10 | quality: |
| 11 | name: Lint, Typecheck, Test, Browser Test, Build | |
| 11 | name: Format, Lint, Typecheck, Test, Browser Test, Build | |
| 12 | 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 13 | 13 | steps: |
| 14 | 14 | - name: Checkout |
| ... | ... | @@ -45,6 +45,9 @@ jobs: |
| 45 | 45 | - name: Install dependencies |
| 46 | 46 | run: bun install --frozen-lockfile |
| 47 | 47 | |
| 48 | - name: Format | |
| 49 | run: bun run fmt:check | |
| 50 | ||
| 48 | 51 | - name: Lint |
| 49 | 52 | run: bun run lint |
| 50 | 53 |
.oxfmtrc.json+3-2| ... | ... | @@ -7,7 +7,8 @@ |
| 7 | 7 | "node_modules", |
| 8 | 8 | "bun.lock", |
| 9 | 9 | "*.tsbuildinfo", |
| 10 | "**/routeTree.gen.ts" | |
| 10 | "**/routeTree.gen.ts", | |
| 11 | "apps/web/public/mockServiceWorker.js" | |
| 11 | 12 | ], |
| 12 | "experimentalSortPackageJson": {} | |
| 13 | "sortPackageJson": {} | |
| 13 | 14 | } |
.plans/01-shared-model-normalization.md+7| ... | ... | @@ -1,20 +1,24 @@ |
| 1 | 1 | # Plan: Centralize Model Normalization in Contracts |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - Removes duplicated logic between: |
| 8 | 10 | - `apps/desktop/src/codexAppServerManager.ts` |
| 9 | 11 | - `apps/renderer/src/model-logic.ts` |
| 10 | 12 | - Prevents behavior drift when model aliases/defaults are updated. |
| 11 | 13 | |
| 12 | 14 | ## Scope |
| 15 | ||
| 13 | 16 | - Add shared model utilities to contracts. |
| 14 | 17 | - Update desktop and renderer to consume shared utilities. |
| 15 | 18 | - Keep renderer-specific display options in renderer. |
| 16 | 19 | |
| 17 | 20 | ## Proposed Changes |
| 21 | ||
| 18 | 22 | 1. Add `packages/contracts/src/model.ts` with: |
| 19 | 23 | - Canonical model list |
| 20 | 24 | - Alias map |
| ... | ... | @@ -29,14 +33,17 @@ Move model alias/default normalization into `packages/contracts` so desktop and |
| 29 | 33 | - Keep renderer tests focused on renderer-only behavior. |
| 30 | 34 | |
| 31 | 35 | ## Risks |
| 36 | ||
| 32 | 37 | - Desktop/renderer may currently rely on slightly different fallback behavior. |
| 33 | 38 | - Import graph must avoid bundling issues for Electron main/preload. |
| 34 | 39 | |
| 35 | 40 | ## Validation |
| 41 | ||
| 36 | 42 | - `bun run test` |
| 37 | 43 | - `bun run typecheck` |
| 38 | 44 | - Manual check that model selection and session start still send expected model slug. |
| 39 | 45 | |
| 40 | 46 | ## Done Criteria |
| 47 | ||
| 41 | 48 | - No duplicated alias/default map in desktop and renderer. |
| 42 | 49 | - Shared model utilities are contract-tested. |
.plans/02-typed-ipc-boundaries.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Strengthen Typed IPC Boundaries in Main Process |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`. |
| 8 | 10 | - Casts can hide contract breakages until runtime. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - Desktop main process IPC registration. |
| 12 | 15 | - Optional shared helper for handler registration. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: |
| 16 | 20 | - Parse payload(s) with Zod schemas |
| 17 | 21 | - Standardize typed handler signatures |
| ... | ... | @@ -24,14 +28,17 @@ Replace loose payload casting in IPC handlers with strict schema parsing and typ |
| 24 | 28 | 4. Add tests for handler parsing failure paths (invalid payloads). |
| 25 | 29 | |
| 26 | 30 | ## Risks |
| 31 | ||
| 27 | 32 | - Refactor can subtly change IPC error shape/messages. |
| 28 | 33 | - Helper abstraction should stay simple and not obscure control flow. |
| 29 | 34 | |
| 30 | 35 | ## Validation |
| 36 | ||
| 31 | 37 | - `bun run test` |
| 32 | 38 | - `bun run typecheck` |
| 33 | 39 | - Manual invalid payload check from renderer/devtools to confirm fast failure. |
| 34 | 40 | |
| 35 | 41 | ## Done Criteria |
| 42 | ||
| 36 | 43 | - No provider handler uses `payload as Parameters<...>`. |
| 37 | 44 | - All IPC entrypoints parse unknown payloads at boundary. |
.plans/03-split-codex-app-server-manager.md+7| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | # Plan: Decompose CodexAppServerManager |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Split `CodexAppServerManager` into smaller modules with clear responsibilities. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/desktop/src/codexAppServerManager.ts` is large and mixes: |
| 8 | 10 | - Process lifecycle |
| 9 | 11 | - JSON-RPC parsing/routing |
| ... | ... | @@ -12,10 +14,12 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. |
| 12 | 14 | - This increases regression risk and slows changes. |
| 13 | 15 | |
| 14 | 16 | ## Scope |
| 17 | ||
| 15 | 18 | - Desktop provider internals only. |
| 16 | 19 | - Keep external behavior/API stable. |
| 17 | 20 | |
| 18 | 21 | ## Proposed Changes |
| 22 | ||
| 19 | 23 | 1. Extract modules: |
| 20 | 24 | - `codex/processLifecycle.ts` |
| 21 | 25 | - `codex/jsonrpcRouter.ts` |
| ... | ... | @@ -29,13 +33,16 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. |
| 29 | 33 | - Session state transitions |
| 30 | 34 | |
| 31 | 35 | ## Risks |
| 36 | ||
| 32 | 37 | - Reordering event handling can change behavior. |
| 33 | 38 | - Must preserve pending request timeout/cancellation semantics. |
| 34 | 39 | |
| 35 | 40 | ## Validation |
| 41 | ||
| 36 | 42 | - Existing tests pass. |
| 37 | 43 | - Add module-level tests for parsing and transition logic. |
| 38 | 44 | |
| 39 | 45 | ## Done Criteria |
| 46 | ||
| 40 | 47 | - Main manager file materially smaller and orchestration-focused. |
| 41 | 48 | - Core protocol/state logic covered by focused tests. |
.plans/04-split-chatview-component.md+7| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | # Plan: Split ChatView into Smaller UI/Logic Units |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/renderer/src/components/ChatView.tsx` is large and handles: |
| 8 | 10 | - Session orchestration |
| 9 | 11 | - Send/interrupt actions |
| ... | ... | @@ -13,10 +15,12 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. |
| 13 | 15 | - Hard to test and maintain as one component. |
| 14 | 16 | |
| 15 | 17 | ## Scope |
| 18 | ||
| 16 | 19 | - Renderer component boundaries and hooks. |
| 17 | 20 | - Keep visual behavior unchanged. |
| 18 | 21 | |
| 19 | 22 | ## Proposed Changes |
| 23 | ||
| 20 | 24 | 1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` |
| 21 | 25 | - `ensureSession` |
| 22 | 26 | - `sendTurn` |
| ... | ... | @@ -29,12 +33,15 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. |
| 29 | 33 | 4. Add focused tests for hook behavior (error handling, session reuse). |
| 30 | 34 | |
| 31 | 35 | ## Risks |
| 36 | ||
| 32 | 37 | - Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send). |
| 33 | 38 | |
| 34 | 39 | ## Validation |
| 40 | ||
| 35 | 41 | - `bun run test` |
| 36 | 42 | - Manual smoke: send, stream, interrupt, model switch. |
| 37 | 43 | |
| 38 | 44 | ## Done Criteria |
| 45 | ||
| 39 | 46 | - `ChatView.tsx` significantly reduced and easier to scan. |
| 40 | 47 | - Session logic isolated from rendering. |
.plans/05-zod-persisted-state-validation.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Move Renderer Persisted-State Validation to Zod |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Use explicit Zod schemas for localStorage state parsing and migration. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/renderer/src/store.ts` has large manual sanitize functions. |
| 8 | 10 | - Manual type guards are verbose and easier to get wrong during schema evolution. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - Renderer state hydration/persistence path. |
| 12 | 15 | - No backend/protocol changes. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add schema module: `apps/renderer/src/persistenceSchema.ts` |
| 16 | 20 | - Persisted payload versions (`v1`, `v2`) |
| 17 | 21 | - Thread/message/project schemas |
| ... | ... | @@ -23,12 +27,15 @@ Use explicit Zod schemas for localStorage state parsing and migration. |
| 23 | 27 | - Unknown thread/project references filtered |
| 24 | 28 | |
| 25 | 29 | ## Risks |
| 30 | ||
| 26 | 31 | - Overly strict schemas could drop valid historical data unexpectedly. |
| 27 | 32 | |
| 28 | 33 | ## Validation |
| 34 | ||
| 29 | 35 | - Unit tests for migration/hydration. |
| 30 | 36 | - Manual reload test with existing localStorage data. |
| 31 | 37 | |
| 32 | 38 | ## Done Criteria |
| 39 | ||
| 33 | 40 | - Store hydration logic is schema-driven. |
| 34 | 41 | - Migration behavior is tested and documented. |
.plans/06-provider-logstream-lifecycle.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Add Provider Log Stream Lifecycle Management |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/desktop/src/providerManager.ts` opens a write stream in constructor. |
| 8 | 10 | - Stream lifecycle is not explicit on shutdown. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - Desktop provider logging behavior. |
| 12 | 15 | - App shutdown integration. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add explicit `dispose()` on `ProviderManager`: |
| 16 | 20 | - Remove event listeners |
| 17 | 21 | - End/close log stream |
| ... | ... | @@ -20,12 +24,15 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and |
| 20 | 24 | 4. Optional: per-session log files under `.logs/providers/`. |
| 21 | 25 | |
| 22 | 26 | ## Risks |
| 27 | ||
| 23 | 28 | - Improper close sequencing may lose final log lines. |
| 24 | 29 | |
| 25 | 30 | ## Validation |
| 31 | ||
| 26 | 32 | - Manual run/quit cycle to ensure no open handle warnings. |
| 27 | 33 | - Confirm logs flush on quit and file descriptors are not leaked. |
| 28 | 34 | |
| 29 | 35 | ## Done Criteria |
| 36 | ||
| 30 | 37 | - ProviderManager owns complete log stream lifecycle. |
| 31 | 38 | - Shutdown path explicitly disposes provider resources. |
.plans/07-ci-quality-gates.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Add CI Workflow for Core Quality Gates |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - Repository currently has no CI workflow files. |
| 8 | 10 | - Quality checks are only local/manual. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - `.github/workflows/ci.yml` |
| 12 | 15 | - Bun + Turbo setup in CI. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add `ci.yml` with jobs: |
| 16 | 20 | - Setup Bun and Node environment |
| 17 | 21 | - Install deps |
| ... | ... | @@ -22,13 +26,16 @@ Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-tes |
| 22 | 26 | 3. Configure caching for Bun/Turbo as appropriate. |
| 23 | 27 | |
| 24 | 28 | ## Risks |
| 29 | ||
| 25 | 30 | - Smoke test may be flaky in headless CI environments. |
| 26 | 31 | - CI runtime can grow if caching is misconfigured. |
| 27 | 32 | |
| 28 | 33 | ## Validation |
| 34 | ||
| 29 | 35 | - Verify workflow runs on a branch PR. |
| 30 | 36 | - Ensure failures surface clearly by job name. |
| 31 | 37 | |
| 32 | 38 | ## Done Criteria |
| 39 | ||
| 33 | 40 | - CI blocks regressions in lint/typecheck/test. |
| 34 | 41 | - Workflow docs added to README. |
.plans/08-precommit-format-and-lint.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Add Pre-Commit Formatting/Lint Hooks |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Introduce pre-commit automation so formatting and basic lint checks happen before commits. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - Current lint failures include formatting-only issues. |
| 8 | 10 | - Shift-left feedback reduces noisy CI failures and cleanup churn. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - Root tooling config and package scripts. |
| 12 | 15 | - No runtime code changes. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). |
| 16 | 20 | 2. Configure staged-file tasks: |
| 17 | 21 | - `biome format --write` |
| ... | ... | @@ -20,13 +24,16 @@ Introduce pre-commit automation so formatting and basic lint checks happen befor |
| 20 | 24 | 4. Keep checks fast to avoid developer friction. |
| 21 | 25 | |
| 22 | 26 | ## Risks |
| 27 | ||
| 23 | 28 | - Slow hooks can frustrate contributors and be bypassed. |
| 24 | 29 | - Need to ensure compatibility with Bun workspace setup. |
| 25 | 30 | |
| 26 | 31 | ## Validation |
| 32 | ||
| 27 | 33 | - Create sample staged changes and verify hook behavior. |
| 28 | 34 | - Confirm formatting fixes are applied automatically. |
| 29 | 35 | |
| 30 | 36 | ## Done Criteria |
| 37 | ||
| 31 | 38 | - Pre-commit hook installed and documented. |
| 32 | 39 | - Formatting-only lint failures drop significantly. |
.plans/09-event-state-test-expansion.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Expand Event/State Transition Test Coverage |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Add focused tests for renderer event handling and session evolution logic. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - Core behavior is event-driven and stateful. |
| 8 | 10 | - Existing renderer tests cover only a subset of timeline/model behavior. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - `apps/renderer/src/session-logic.test.ts` |
| 12 | 15 | - Optional reducer tests for `apps/renderer/src/store.ts`. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Add tests for `evolveSession`: |
| 16 | 20 | - `thread/started` |
| 17 | 21 | - `turn/started` |
| ... | ... | @@ -24,12 +28,15 @@ Add focused tests for renderer event handling and session evolution logic. |
| 24 | 28 | 3. Add reducer integration tests for `APPLY_EVENT`. |
| 25 | 29 | |
| 26 | 30 | ## Risks |
| 31 | ||
| 27 | 32 | - Tests may be brittle if event payload fixtures are too coupled to implementation details. |
| 28 | 33 | |
| 29 | 34 | ## Validation |
| 35 | ||
| 30 | 36 | - `bun run test` |
| 31 | 37 | - Ensure new tests remain deterministic and fast. |
| 32 | 38 | |
| 33 | 39 | ## Done Criteria |
| 40 | ||
| 34 | 41 | - High-risk event transitions are covered by unit tests. |
| 35 | 42 | - Regressions in stream assembly/session status are caught quickly. |
.plans/10-unify-process-session-abstraction.md+7| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | 1 | # Plan: Unify Process and PTY Session Abstractions in ProcessManager |
| 2 | 2 | |
| 3 | 3 | ## Summary |
| 4 | ||
| 4 | 5 | Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. |
| 5 | 6 | |
| 6 | 7 | ## Motivation |
| 8 | ||
| 7 | 9 | - `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic. |
| 8 | 10 | - New execution backends/providers will multiply complexity. |
| 9 | 11 | |
| 10 | 12 | ## Scope |
| 13 | ||
| 11 | 14 | - Desktop process execution internals. |
| 12 | 15 | - Preserve public `ProcessManager` API. |
| 13 | 16 | |
| 14 | 17 | ## Proposed Changes |
| 18 | ||
| 15 | 19 | 1. Introduce internal interface (e.g. `RuntimeSession`): |
| 16 | 20 | - `write(data)` |
| 17 | 21 | - `kill()` |
| ... | ... | @@ -24,12 +28,15 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-pr |
| 24 | 28 | 5. Add tests for both implementations. |
| 25 | 29 | |
| 26 | 30 | ## Risks |
| 31 | ||
| 27 | 32 | - PTY behavior differs by platform; abstraction must not hide required differences. |
| 28 | 33 | |
| 29 | 34 | ## Validation |
| 35 | ||
| 30 | 36 | - Existing `processManager.test.ts` passes. |
| 31 | 37 | - Add PTY-path tests where feasible. |
| 32 | 38 | |
| 33 | 39 | ## Done Criteria |
| 40 | ||
| 34 | 41 | - Manager no longer branches per backend in `write/kill/killAll`. |
| 35 | 42 | - Session backends are independently testable. |
.plans/11-effect.md+1-3| ... | ... | @@ -1,5 +1,3 @@ |
| 1 | ||
| 2 | ||
| 3 | 1 | PR 1: Service contracts + error taxonomy |
| 4 | 2 | Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. |
| 5 | 3 | Add 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. |
| 39 | 37 | PR 10: Cleanup + deprecation removal |
| 40 | 38 | Remove legacy class implementations/adapters once parity is proven. |
| 41 | 39 | Finalize layer composition and startup graph docs. |
| 42 | Add architecture notes for service boundaries and error model. | |
| \ No newline at end of file | ||
| 40 | Add architecture notes for service boundaries and error model. |
.plans/12-effect-new.md+9| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | 1 | # Effect Migration Plan (From Current State) |
| 2 | 2 | |
| 3 | 3 | Current status summary: |
| 4 | ||
| 4 | 5 | - Service contracts, typed errors, and most checkpoint/persistence services exist. |
| 5 | 6 | - `ProviderServiceLive` is already native orchestration (not a thin adapter). |
| 6 | 7 | - Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. |
| 7 | 8 | - Checkpoint flow now avoids snapshot re-sync and is write-time driven. |
| 8 | 9 | |
| 9 | 10 | ## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` |
| 11 | ||
| 10 | 12 | - Build one runtime layer graph for provider + checkpoint + persistence + orchestration. |
| 11 | 13 | - Resolve `ProviderService` from runtime in `wsServer`. |
| 12 | 14 | - Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. |
| ... | ... | @@ -14,12 +16,14 @@ Current status summary: |
| 14 | 16 | - Keep WS method/push payloads identical. |
| 15 | 17 | |
| 16 | 18 | ## PR 2: Runtime Composition + Startup Ownership |
| 19 | ||
| 17 | 20 | - Create/centralize `AppLive` composition for server startup. |
| 18 | 21 | - Ensure outer runtime provides Node/platform services once. |
| 19 | 22 | - Ensure migrations run at startup via scoped/layer startup path. |
| 20 | 23 | - Remove ad-hoc service initialization in request-time paths. |
| 21 | 24 | |
| 22 | 25 | ## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants |
| 26 | ||
| 23 | 27 | - Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. |
| 24 | 28 | - Remove per-session lock/cwd map leaks. |
| 25 | 29 | - Keep strict invariant model: |
| ... | ... | @@ -29,12 +33,14 @@ Current status summary: |
| 29 | 33 | - Add tests for lifecycle cleanup and invariant-failure surfaces. |
| 30 | 34 | |
| 31 | 35 | ## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) |
| 36 | ||
| 32 | 37 | - Keep `ProviderService` as the public event surface. |
| 33 | 38 | - Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. |
| 34 | 39 | - Keep API as `subscribeToEvents` unless we explicitly choose stream API later. |
| 35 | 40 | - Add tests for ordering and subscriber isolation under load. |
| 36 | 41 | |
| 37 | 42 | ## PR 5: Codex Runtime Split (Scoped Effect Core) |
| 43 | ||
| 38 | 44 | - Extract `CodexAppServerManager` responsibilities into Effect-native layers: |
| 39 | 45 | - scoped process lifecycle |
| 40 | 46 | - RPC request/response + pending map via `Deferred` |
| ... | ... | @@ -43,16 +49,19 @@ Current status summary: |
| 43 | 49 | - Preserve protocol behavior and timeout semantics. |
| 44 | 50 | |
| 45 | 51 | ## PR 6: Codex Protocol Decode Hardening |
| 52 | ||
| 46 | 53 | - Replace ad-hoc unknown parsing with runtime schema decode. |
| 47 | 54 | - Map decode failures to typed tagged errors with `cause` retained. |
| 48 | 55 | - Add regression tests for malformed/partial protocol frames. |
| 49 | 56 | |
| 50 | 57 | ## PR 7: Remove Legacy Provider Stack |
| 58 | ||
| 51 | 59 | - Remove `ProviderManager` + legacy checkpoint integration from runtime path. |
| 52 | 60 | - Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). |
| 53 | 61 | - Update tests to assert only Effect service path is used. |
| 54 | 62 | |
| 55 | 63 | ## PR 8: Final Cleanup + Docs |
| 64 | ||
| 56 | 65 | - Update architecture docs with final layer graph and service boundaries. |
| 57 | 66 | - Document error model and recovery semantics. |
| 58 | 67 | - Trim dead compatibility code and stale plan references. |
.plans/13-provider-service-integration-tests.md+19-6| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | # ProviderService Integration Test Plan |
| 2 | 2 | |
| 3 | 3 | Goal: |
| 4 | ||
| 4 | 5 | - Validate end-to-end `ProviderService` behavior with real layers: |
| 5 | 6 | - `ProviderServiceLive` |
| 6 | 7 | - `CheckpointServiceLive` |
| ... | ... | @@ -13,6 +14,7 @@ Goal: |
| 13 | 14 | ## Test Harness |
| 14 | 15 | |
| 15 | 16 | Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: |
| 17 | ||
| 16 | 18 | - Service contract: `ProviderAdapterShape<ProviderAdapterError>`. |
| 17 | 19 | - Internal state: |
| 18 | 20 | - session registry (session + cwd + threadId) |
| ... | ... | @@ -29,13 +31,15 @@ Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Lay |
| 29 | 31 | - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. |
| 30 | 32 | |
| 31 | 33 | Use real git-backed temporary workspaces in integration tests: |
| 34 | ||
| 32 | 35 | - initialize repo with baseline commit |
| 33 | 36 | - run provider turn in workspace |
| 34 | 37 | - assert checkpoint diffs against real git refs |
| 35 | 38 | |
| 36 | 39 | ## Core Integration Specs |
| 37 | 40 | |
| 38 | 1) `startSession` initializes checkpoint root exactly once | |
| 41 | 1. `startSession` initializes checkpoint root exactly once | |
| 42 | ||
| 39 | 43 | - Arrange: |
| 40 | 44 | - start provider session in git repo. |
| 41 | 45 | - Assert: |
| ... | ... | @@ -43,7 +47,8 @@ Use real git-backed temporary workspaces in integration tests: |
| 43 | 47 | - checkpoint ref exists in git. |
| 44 | 48 | - second `startSession` for new session creates a new independent root. |
| 45 | 49 | |
| 46 | 2) Turn without filesystem change | |
| 50 | 2. Turn without filesystem change | |
| 51 | ||
| 47 | 52 | - Arrange: |
| 48 | 53 | - emit normal turn events, no file mutation. |
| 49 | 54 | - Assert: |
| ... | ... | @@ -54,7 +59,8 @@ Use real git-backed temporary workspaces in integration tests: |
| 54 | 59 | - `listCheckpoints` returns root + turn 1. |
| 55 | 60 | - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. |
| 56 | 61 | |
| 57 | 3) Turn with filesystem change | |
| 62 | 3. Turn with filesystem change | |
| 63 | ||
| 58 | 64 | - Arrange: |
| 59 | 65 | - mutate `README.md` during turn. |
| 60 | 66 | - Assert: |
| ... | ... | @@ -62,7 +68,8 @@ Use real git-backed temporary workspaces in integration tests: |
| 62 | 68 | - `getCheckpointDiff(0 -> 1)` contains file path and hunk. |
| 63 | 69 | - persisted checkpoint metadata includes non-empty `checkpointRef`. |
| 64 | 70 | |
| 65 | 4) Multi-turn sequencing and checkpoint monotonicity | |
| 71 | 4. Multi-turn sequencing and checkpoint monotonicity | |
| 72 | ||
| 66 | 73 | - Arrange: |
| 67 | 74 | - turn 1: no file change |
| 68 | 75 | - turn 2: file change |
| ... | ... | @@ -72,7 +79,8 @@ Use real git-backed temporary workspaces in integration tests: |
| 72 | 79 | - latest checkpoint is marked current. |
| 73 | 80 | - diffs for adjacent turns map to expected filesystem deltas. |
| 74 | 81 | |
| 75 | 5) Revert to checkpoint | |
| 82 | 5. Revert to checkpoint | |
| 83 | ||
| 76 | 84 | - Arrange: |
| 77 | 85 | - execute 3 turns with at least one file-changing turn. |
| 78 | 86 | - call `revertToCheckpoint(turnCount=1)`. |
| ... | ... | @@ -82,7 +90,8 @@ Use real git-backed temporary workspaces in integration tests: |
| 82 | 90 | - DB rows for turns >1 are removed. |
| 83 | 91 | - later refs are deleted from git. |
| 84 | 92 | |
| 85 | 6) Capture failure surface | |
| 93 | 6. Capture failure surface | |
| 94 | ||
| 86 | 95 | - Arrange: |
| 87 | 96 | - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. |
| 88 | 97 | - Assert: |
| ... | ... | @@ -92,6 +101,7 @@ Use real git-backed temporary workspaces in integration tests: |
| 92 | 101 | ## WebSocket Coverage (Thin Integration) |
| 93 | 102 | |
| 94 | 103 | Add one ws server integration spec: |
| 104 | ||
| 95 | 105 | - Subscribe to `providers.event`. |
| 96 | 106 | - Run a deterministic provider turn through ws methods. |
| 97 | 107 | - Assert push stream includes: |
| ... | ... | @@ -101,10 +111,13 @@ Add one ws server integration spec: |
| 101 | 111 | ## Proposed PR Split |
| 102 | 112 | |
| 103 | 113 | PR A: |
| 114 | ||
| 104 | 115 | - Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). |
| 105 | 116 | |
| 106 | 117 | PR B: |
| 118 | ||
| 107 | 119 | - Core ProviderService integration specs (cases 1-4). |
| 108 | 120 | |
| 109 | 121 | PR C: |
| 122 | ||
| 110 | 123 | - Revert + failure-path specs (cases 5-6) + ws thin integration spec. |
.plans/14-server-authoritative-event-sourcing-cleanup.md+12| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | # Server-Authoritative Event-Sourcing Cleanup Plan |
| 2 | 2 | |
| 3 | 3 | Goal: |
| 4 | ||
| 4 | 5 | - Move to a cleaner service architecture with: |
| 5 | 6 | - durable, server-authoritative event sourcing |
| 6 | 7 | - strict command routing/validation |
| ... | ... | @@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite |
| 85 | 86 | ## Commit Series |
| 86 | 87 | |
| 87 | 88 | ### Commit 1: Split public vs system orchestration command contracts |
| 89 | ||
| 88 | 90 | - Create separate schemas/types: |
| 89 | 91 | - `ClientOrchestrationCommandSchema` |
| 90 | 92 | - `SystemOrchestrationCommandSchema` |
| ... | ... | @@ -100,6 +102,7 @@ CheckpointCatalog ------> SQLite |
| 100 | 102 | - preserve internal dispatch functionality for system commands |
| 101 | 103 | |
| 102 | 104 | ### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary |
| 105 | ||
| 103 | 106 | - Add dedicated router service to validate, authorize, and route commands. |
| 104 | 107 | - Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. |
| 105 | 108 | - Add aggregate-level invariant checks before append (thread exists, project exists, etc.). |
| ... | ... | @@ -113,6 +116,7 @@ CheckpointCatalog ------> SQLite |
| 113 | 116 | - handler happy-path tests per command type |
| 114 | 117 | |
| 115 | 118 | ### Commit 3: Harden event store for idempotency + optimistic append metadata |
| 119 | ||
| 116 | 120 | - Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). |
| 117 | 121 | - Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. |
| 118 | 122 | - Add optional aggregate version metadata for future optimistic concurrency. |
| ... | ... | @@ -125,6 +129,7 @@ CheckpointCatalog ------> SQLite |
| 125 | 129 | - concurrent append behavior stays ordered and deterministic |
| 126 | 130 | |
| 127 | 131 | ### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` |
| 132 | ||
| 128 | 133 | - Create `ProviderRuntimeIngestionService` that: |
| 129 | 134 | - subscribes to `ProviderService.streamEvents` |
| 130 | 135 | - translates runtime events into orchestration commands |
| ... | ... | @@ -139,6 +144,7 @@ CheckpointCatalog ------> SQLite |
| 139 | 144 | - ws integration confirms same external push behavior |
| 140 | 145 | |
| 141 | 146 | ### Commit 5: Make session directory durable (`ProviderSessionRegistry`) |
| 147 | ||
| 142 | 148 | - Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. |
| 143 | 149 | - Keep in-memory cache optional, but source of truth must be persistent. |
| 144 | 150 | - Add startup reconciliation to prune dead sessions / keep known thread mapping. |
| ... | ... | @@ -152,6 +158,7 @@ CheckpointCatalog ------> SQLite |
| 152 | 158 | - stale session cleanup semantics |
| 153 | 159 | |
| 154 | 160 | ### Commit 6: Re-key checkpoint metadata from session to thread identity |
| 161 | ||
| 155 | 162 | - Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. |
| 156 | 163 | - Keep `session_id` as nullable metadata only. |
| 157 | 164 | - Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. |
| ... | ... | @@ -165,6 +172,7 @@ CheckpointCatalog ------> SQLite |
| 165 | 172 | - revert/diff still work after session churn |
| 166 | 173 | |
| 167 | 174 | ### Commit 7: Add durable projection persistence for orchestration read models |
| 175 | ||
| 168 | 176 | - Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. |
| 169 | 177 | - Keep event stream as source of truth; projection rebuild stays deterministic. |
| 170 | 178 | - `getSnapshot` reads from projection store (memory cache optional). |
| ... | ... | @@ -177,6 +185,7 @@ CheckpointCatalog ------> SQLite |
| 177 | 185 | - projection rebuild from events yields same result as previous reducer semantics |
| 178 | 186 | |
| 179 | 187 | ### Commit 8: Narrow `ProviderService` responsibilities |
| 188 | ||
| 180 | 189 | - Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. |
| 181 | 190 | - Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. |
| 182 | 191 | - Preserve adapter pluggability and provider-neutral contracts. |
| ... | ... | @@ -188,6 +197,7 @@ CheckpointCatalog ------> SQLite |
| 188 | 197 | - checkpoint capture still triggered by turn completion through new coordinator |
| 189 | 198 | |
| 190 | 199 | ### Commit 9: Look over schemas (contracts and events) |
| 200 | ||
| 191 | 201 | - Scan for unused schemas. |
| 192 | 202 | - Use effect/Schema everywhere |
| 193 | 203 | - Analyze which we need |
| ... | ... | @@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite |
| 196 | 206 | - Persistence entities |
| 197 | 207 | |
| 198 | 208 | ### Commit 10: Remove dead legacy path and finalize docs |
| 209 | ||
| 199 | 210 | - Remove unused legacy manager/store path from active architecture: |
| 200 | 211 | - `providerManager.ts` |
| 201 | 212 | - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) |
| ... | ... | @@ -210,6 +221,7 @@ CheckpointCatalog ------> SQLite |
| 210 | 221 | - no regressions in WS protocol behavior |
| 211 | 222 | |
| 212 | 223 | ## Risk Controls |
| 224 | ||
| 213 | 225 | - Keep WS method names and payload contracts stable throughout. |
| 214 | 226 | - Gate each commit with targeted integration tests before moving forward. |
| 215 | 227 | - 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 @@ |
| 1 | 1 | Rewrite `createServer` and `index.ts` to be Effect native. |
| 2 | 2 | |
| 3 | 3 | Maybe use `effect/unstable/Socket` for the web socket server |
| 4 | ||
| 4 | 5 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts |
| 5 | 6 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts |
| 6 | 7 | |
| 7 | 8 | - Migrate remaining runtime code to Effect |
| 8 | 9 | - `gitManager` -> `src/git` |
| 9 | 10 | - `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 @@ |
| 24 | 24 | - Mark invalid/false-positive items with explicit rationale. |
| 25 | 25 | |
| 26 | 26 | Exit criteria: |
| 27 | ||
| 27 | 28 | - Every open thread is mapped to one canonical fix item or marked invalid. |
| 28 | 29 | |
| 29 | 30 | ## Phase 1: Runtime Survival and Critical Event Wiring |
| 30 | 31 | |
| 31 | 32 | Related bug groups solved together: |
| 33 | ||
| 32 | 34 | - Worker loop/fiber fatal error handling in orchestration reactors. |
| 33 | 35 | - WebSocket message error boundaries and unhandled rejection guards. |
| 34 | 36 | - Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected). |
| 35 | 37 | |
| 36 | 38 | Primary files: |
| 39 | ||
| 37 | 40 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` |
| 38 | 41 | - `apps/server/src/orchestration/Layers/CheckpointReactor.ts` |
| 39 | 42 | - `apps/server/src/wsServer.ts` |
| 40 | 43 | |
| 41 | 44 | Exit criteria: |
| 45 | ||
| 42 | 46 | - A single event-processing failure cannot permanently stop ingestion/reactor loops. |
| 43 | 47 | - WS message handling cannot produce unhandled promise rejections. |
| 44 | 48 | - Invalid provider-event-channel review findings are closed with architecture rationale. |
| ... | ... | @@ -46,17 +50,20 @@ Exit criteria: |
| 46 | 50 | ## Phase 2: State Consistency and Ordering |
| 47 | 51 | |
| 48 | 52 | Related bug groups solved together: |
| 53 | ||
| 49 | 54 | - Fire-and-forget revert completion causing consistency windows. |
| 50 | 55 | - Non-atomic append/projection paths and retry behavior. |
| 51 | 56 | - Race-sensitive thread/event association issues. |
| 52 | 57 | |
| 53 | 58 | Primary files: |
| 59 | ||
| 54 | 60 | - `apps/server/src/orchestration/Layers/CheckpointReactor.ts` |
| 55 | 61 | - `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` |
| 56 | 62 | - `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` |
| 57 | 63 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` |
| 58 | 64 | |
| 59 | 65 | Exit criteria: |
| 66 | ||
| 60 | 67 | - Revert flow is deterministically reflected in read model updates. |
| 61 | 68 | - Append/project failure mode is explicit and safe under retry. |
| 62 | 69 | - No cross-thread misassociation under concurrent runtime events. |
| ... | ... | @@ -64,12 +71,14 @@ Exit criteria: |
| 64 | 71 | ## Phase 3: Checkpointing Correctness Bundle |
| 65 | 72 | |
| 66 | 73 | Related bug groups solved together: |
| 74 | ||
| 67 | 75 | - Checkpoint input normalization consistency. |
| 68 | 76 | - Snapshot/projector coverage mismatches. |
| 69 | 77 | - Checkpoint ref/workspace CWD utility duplication. |
| 70 | 78 | - Checkpoint diff/error handling behavior gaps. |
| 71 | 79 | |
| 72 | 80 | Primary files: |
| 81 | ||
| 73 | 82 | - `apps/server/src/checkpointing/Layers/CheckpointStore.ts` |
| 74 | 83 | - `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts` |
| 75 | 84 | - `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` |
| ... | ... | @@ -77,6 +86,7 @@ Primary files: |
| 77 | 86 | - `apps/server/src/wsServer.ts` |
| 78 | 87 | |
| 79 | 88 | Exit criteria: |
| 89 | ||
| 80 | 90 | - Checkpoint capture/restore/revert paths use one normalization policy. |
| 81 | 91 | - Required projectors are actually represented in snapshot reads. |
| 82 | 92 | - Shared checkpoint/ref/CWD helpers are centralized. |
| ... | ... | @@ -84,27 +94,32 @@ Exit criteria: |
| 84 | 94 | ## Phase 4: Memory and Lifecycle Hygiene |
| 85 | 95 | |
| 86 | 96 | Related bug groups solved together: |
| 97 | ||
| 87 | 98 | - Unbounded in-memory dedup sets/maps. |
| 88 | 99 | - Missing cleanup/lifecycle protections in long-lived effects/resources. |
| 89 | 100 | |
| 90 | 101 | Primary files: |
| 102 | ||
| 91 | 103 | - `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` |
| 92 | 104 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` |
| 93 | 105 | - `apps/server/src/config.ts` |
| 94 | 106 | |
| 95 | 107 | Exit criteria: |
| 108 | ||
| 96 | 109 | - Long-running server memory does not grow unbounded from dedup bookkeeping. |
| 97 | 110 | - Resource cleanup paths are registered for interruption/shutdown. |
| 98 | 111 | |
| 99 | 112 | ## Phase 5: Transport, Parsing, and Platform Edge Cases |
| 100 | 113 | |
| 101 | 114 | Related bug groups solved together: |
| 115 | ||
| 102 | 116 | - UTF-8 chunk boundary decode correctness. |
| 103 | 117 | - Markdown/file-link parsing edge cases. |
| 104 | 118 | - Shell/OS-specific PATH parsing behavior. |
| 105 | 119 | - Git rename parsing and small keybinding edge cases. |
| 106 | 120 | |
| 107 | 121 | Primary files: |
| 122 | ||
| 108 | 123 | - `apps/server/src/wsServer.ts` |
| 109 | 124 | - `apps/server/src/git/Layers/CodexTextGeneration.ts` |
| 110 | 125 | - `apps/web/src/markdown-links.ts` |
| ... | ... | @@ -113,23 +128,27 @@ Primary files: |
| 113 | 128 | - `apps/server/src/keybindings.ts` |
| 114 | 129 | |
| 115 | 130 | Exit criteria: |
| 131 | ||
| 116 | 132 | - Edge-case parsers are robust across valid but non-trivial inputs. |
| 117 | 133 | - Platform-dependent command behavior has safe fallbacks. |
| 118 | 134 | |
| 119 | 135 | ## Phase 6: Build and Maintainability Cleanup |
| 120 | 136 | |
| 121 | 137 | Related bug groups solved together: |
| 138 | ||
| 122 | 139 | - Build script/runtime assumption cleanup. |
| 123 | 140 | - Redundant error-union declarations and utility/type duplication. |
| 124 | 141 | - Non-functional cleanup comments/docs markers. |
| 125 | 142 | |
| 126 | 143 | Primary files: |
| 144 | ||
| 127 | 145 | - `apps/server/package.json` |
| 128 | 146 | - `apps/server/src/checkpointing/Errors.ts` |
| 129 | 147 | - Shared utility locations introduced during earlier phases |
| 130 | 148 | - `AGENTS.md` (if cleanup is still pending) |
| 131 | 149 | |
| 132 | 150 | Exit criteria: |
| 151 | ||
| 133 | 152 | - Build path is explicit and environment-safe. |
| 134 | 153 | - Redundant types/utilities are removed in favor of single sources of truth. |
| 135 | 154 | |
| ... | ... | @@ -140,6 +159,7 @@ Exit criteria: |
| 140 | 159 | - Resolve threads with fix references per canonical checklist item. |
| 141 | 160 | |
| 142 | 161 | Exit criteria: |
| 162 | ||
| 143 | 163 | - Lint passes. |
| 144 | 164 | - Backend tests pass. |
| 145 | 165 | - 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_ |
| 5 | 5 | This is the working checklist for remediation execution. |
| 6 | 6 | |
| 7 | 7 | Status values: |
| 8 | ||
| 8 | 9 | - `TODO`: Not started |
| 9 | 10 | - `IN_PROGRESS`: Currently being worked |
| 10 | 11 | - `BLOCKED`: Waiting on decision/dependency |
| ... | ... | @@ -150,7 +151,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` |
| 150 | 151 | - Area: `WebSocket robustness` |
| 151 | 152 | - File: `apps/web/src/wsTransport.ts:59` |
| 152 | 153 | - 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. | |
| 154 | 155 | |
| 155 | 156 | ### Phase 2 |
| 156 | 157 | |
| ... | ... | @@ -181,7 +182,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` |
| 181 | 182 | - Threads: PRRT_kwDORLtfbc5whxJO |
| 182 | 183 | - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. |
| 183 | 184 | |
| 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> | |
| 185 | 186 | - Status: `DONE` |
| 186 | 187 | - Verdict: `valid` |
| 187 | 188 | - Severity: `Medium` |
| ... | ... | @@ -198,7 +199,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` |
| 198 | 199 | - Severity: `Medium` |
| 199 | 200 | - Area: `Checkpointing correctness` |
| 200 | 201 | - 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)) | |
| 202 | 203 | - 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. |
| 203 | 204 | |
| 204 | 205 | - [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` |
| 250 | 251 | |
| 251 | 252 | ### Phase 5 |
| 252 | 253 | |
| 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> | |
| 254 | 255 | - Status: `TODO` |
| 255 | 256 | - Verdict: `valid` |
| 256 | 257 | - Severity: `Medium` |
.plans/spec-1-1-cutover-plan.md+51| ... | ... | @@ -3,13 +3,16 @@ |
| 3 | 3 | Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. |
| 4 | 4 | |
| 5 | 5 | Execution mode for this plan: |
| 6 | ||
| 6 | 7 | - Hard cutover only. Existing DB and migration history are disposable. |
| 7 | 8 | - Intermediate steps are allowed to break runtime, tests, typecheck, and lint. |
| 8 | 9 | - We optimize for small, reviewable work units, not continuous app operability. |
| 9 | 10 | - Only the final gate requires everything to run cleanly. |
| 10 | 11 | |
| 11 | 12 | ## 1. Freeze SPEC contract as source of truth |
| 13 | ||
| 12 | 14 | Work units: |
| 15 | ||
| 13 | 16 | - Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. |
| 14 | 17 | - Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. |
| 15 | 18 | - Add app-level requirements per row: writer path, reader path, and owning module. |
| ... | ... | @@ -17,17 +20,22 @@ Work units: |
| 17 | 20 | - Identify any ambiguous spec lines and record a concrete interpretation in the matrix. |
| 18 | 21 | |
| 19 | 22 | Deliverables: |
| 23 | ||
| 20 | 24 | - Complete matrix file with no unclassified rows. |
| 21 | 25 | - Single source checklist used by all later steps. |
| 22 | 26 | |
| 23 | 27 | Breakage allowed: |
| 28 | ||
| 24 | 29 | - No code changes required yet. |
| 25 | 30 | |
| 26 | 31 | Exit criteria: |
| 32 | ||
| 27 | 33 | - Every requirement in `7.1`-`7.4` has exactly one matrix row. |
| 28 | 34 | |
| 29 | 35 | ## 2. Hard cutover migrations (replace current migration set) |
| 36 | ||
| 30 | 37 | Work units: |
| 38 | ||
| 31 | 39 | - Delete the current legacy migration files and rewrite migration loader ordering. |
| 32 | 40 | - Create `001_orchestration_events.ts` with full envelope columns and required event indexes. |
| 33 | 41 | - Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. |
| ... | ... | @@ -47,17 +55,22 @@ Work units: |
| 47 | 55 | - Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. |
| 48 | 56 | |
| 49 | 57 | Deliverables: |
| 58 | ||
| 50 | 59 | - New 5-file migration chain. |
| 51 | 60 | - Updated migration loader references only new migrations. |
| 52 | 61 | |
| 53 | 62 | Breakage allowed: |
| 63 | ||
| 54 | 64 | - Repositories/services can be temporarily broken due to removed old tables. |
| 55 | 65 | |
| 56 | 66 | Exit criteria: |
| 67 | ||
| 57 | 68 | - Fresh DB initializes with only canonical tables plus migration bookkeeping. |
| 58 | 69 | |
| 59 | 70 | ## 3. Align persistence row/request schemas to DB 1:1 |
| 71 | ||
| 60 | 72 | Work units: |
| 73 | ||
| 61 | 74 | - Define row schemas for each canonical table (contracts or persistence layer module). |
| 62 | 75 | - Define request schemas for every insert/update/query operation touching canonical tables. |
| 63 | 76 | - Remove or deprecate row/request schemas tied to deleted legacy tables. |
| ... | ... | @@ -65,17 +78,22 @@ Work units: |
| 65 | 78 | - Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). |
| 66 | 79 | |
| 67 | 80 | Deliverables: |
| 81 | ||
| 68 | 82 | - Canonical row/request schemas committed. |
| 69 | 83 | - Zero references to legacy row schemas in active code paths. |
| 70 | 84 | |
| 71 | 85 | Breakage allowed: |
| 86 | ||
| 72 | 87 | - Runtime can still fail while query layers are being rewired. |
| 73 | 88 | |
| 74 | 89 | Exit criteria: |
| 90 | ||
| 75 | 91 | - Every canonical table used in code has a typed row schema and typed request schema. |
| 76 | 92 | |
| 77 | 93 | ## 4. Rewrite event store for full persisted envelope |
| 94 | ||
| 78 | 95 | Work units: |
| 96 | ||
| 79 | 97 | - Refactor append path to write full envelope fields: |
| 80 | 98 | - `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` |
| 81 | 99 | - Implement stream version assignment/checking per aggregate stream. |
| ... | ... | @@ -84,16 +102,21 @@ Work units: |
| 84 | 102 | - Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). |
| 85 | 103 | |
| 86 | 104 | Deliverables: |
| 105 | ||
| 87 | 106 | - Event store append/replay fully aligned with canonical envelope. |
| 88 | 107 | |
| 89 | 108 | Breakage allowed: |
| 109 | ||
| 90 | 110 | - Command dispatch flow can be partially broken until receipts/projectors are updated. |
| 91 | 111 | |
| 92 | 112 | Exit criteria: |
| 113 | ||
| 93 | 114 | - Event store no longer depends on legacy event table shape. |
| 94 | 115 | |
| 95 | 116 | ## 5. Add command receipt idempotency |
| 117 | ||
| 96 | 118 | Work units: |
| 119 | ||
| 97 | 120 | - Introduce persistence access layer for `orchestration_command_receipts`. |
| 98 | 121 | - In command dispatch flow, check existing receipt by `commandId` before append. |
| 99 | 122 | - On first execution, persist accepted receipt with `resultSequence`. |
| ... | ... | @@ -102,16 +125,21 @@ Work units: |
| 102 | 125 | - Ensure receipt write and event append ordering is deterministic. |
| 103 | 126 | |
| 104 | 127 | Deliverables: |
| 128 | ||
| 105 | 129 | - Dispatch path with idempotency behavior wired through receipts. |
| 106 | 130 | |
| 107 | 131 | Breakage allowed: |
| 132 | ||
| 108 | 133 | - Snapshot/read model may still be inconsistent until projectors are fully wired. |
| 109 | 134 | |
| 110 | 135 | Exit criteria: |
| 136 | ||
| 111 | 137 | - Duplicate command IDs no longer create duplicate events. |
| 112 | 138 | |
| 113 | 139 | ## 6. Build DB-backed projection pipeline |
| 140 | ||
| 114 | 141 | Work units: |
| 142 | ||
| 115 | 143 | - Create projector runner that consumes events and applies table-specific projections. |
| 116 | 144 | - Implement projector handlers for each projection table. |
| 117 | 145 | - For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. |
| ... | ... | @@ -120,16 +148,21 @@ Work units: |
| 120 | 148 | - Add safe resume logic from projector `last_applied_sequence`. |
| 121 | 149 | |
| 122 | 150 | Deliverables: |
| 151 | ||
| 123 | 152 | - Persistent projector pipeline writing all `projection_*` tables. |
| 124 | 153 | |
| 125 | 154 | Breakage allowed: |
| 155 | ||
| 126 | 156 | - Web/API layer may still read old in-memory model until step 7. |
| 127 | 157 | |
| 128 | 158 | Exit criteria: |
| 159 | ||
| 129 | 160 | - Events drive projection rows in DB; projection state advances transactionally. |
| 130 | 161 | |
| 131 | 162 | ## 7. Move RPC reads to projections and diff blobs |
| 163 | ||
| 132 | 164 | Work units: |
| 165 | ||
| 133 | 166 | - Implement snapshot query service reading only projection tables. |
| 134 | 167 | - Build thread hydration from projection rows: messages, activities, checkpoints, session. |
| 135 | 168 | - Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. |
| ... | ... | @@ -138,16 +171,21 @@ Work units: |
| 138 | 171 | - Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. |
| 139 | 172 | |
| 140 | 173 | Deliverables: |
| 174 | ||
| 141 | 175 | - `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. |
| 142 | 176 | |
| 143 | 177 | Breakage allowed: |
| 178 | ||
| 144 | 179 | - Provider runtime persistence may still be partially legacy until step 8. |
| 145 | 180 | |
| 146 | 181 | Exit criteria: |
| 182 | ||
| 147 | 183 | - No orchestration read RPC depends on legacy tables or in-memory-only state. |
| 148 | 184 | |
| 149 | 185 | ## 8. Migrate provider runtime persistence to canonical table |
| 186 | ||
| 150 | 187 | Work units: |
| 188 | ||
| 151 | 189 | - Create repository/service for `provider_session_runtime`. |
| 152 | 190 | - Update adapter/session manager to persist runtime/resume cursor in new table. |
| 153 | 191 | - Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. |
| ... | ... | @@ -155,16 +193,21 @@ Work units: |
| 155 | 193 | - Verify restart/resume path reads runtime state from canonical table only. |
| 156 | 194 | |
| 157 | 195 | Deliverables: |
| 196 | ||
| 158 | 197 | - Provider runtime state entirely backed by `provider_session_runtime`. |
| 159 | 198 | |
| 160 | 199 | Breakage allowed: |
| 200 | ||
| 161 | 201 | - Some legacy interfaces may still exist but should be disconnected. |
| 162 | 202 | |
| 163 | 203 | Exit criteria: |
| 204 | ||
| 164 | 205 | - Runtime restore no longer reads/writes legacy provider session persistence. |
| 165 | 206 | |
| 166 | 207 | ## 9. Remove old cruft aggressively |
| 208 | ||
| 167 | 209 | Work units: |
| 210 | ||
| 168 | 211 | - Delete legacy repositories/services that map to removed tables. |
| 169 | 212 | - Remove dead migration imports and obsolete persistence service interfaces. |
| 170 | 213 | - Remove compatibility code paths that translate legacy row shapes. |
| ... | ... | @@ -172,16 +215,21 @@ Work units: |
| 172 | 215 | - Update internal docs/comments to reference canonical projection/event model only. |
| 173 | 216 | |
| 174 | 217 | Deliverables: |
| 218 | ||
| 175 | 219 | - Legacy persistence and translation layers removed from active codebase. |
| 176 | 220 | |
| 177 | 221 | Breakage allowed: |
| 222 | ||
| 178 | 223 | - Temporary compile failures acceptable while deletion/refactor is in progress. |
| 179 | 224 | |
| 180 | 225 | Exit criteria: |
| 226 | ||
| 181 | 227 | - No production code path references deleted legacy tables/services. |
| 182 | 228 | |
| 183 | 229 | ## 10. Final verification gate (first point where green is required) |
| 230 | ||
| 184 | 231 | Work units: |
| 232 | ||
| 185 | 233 | - Add migration tests that assert canonical tables, columns, constraints, and indexes. |
| 186 | 234 | - Add event store tests for envelope persistence, metadata, actor kind, and replay. |
| 187 | 235 | - Add receipt idempotency tests for accept/reject/duplicate paths. |
| ... | ... | @@ -192,10 +240,13 @@ Work units: |
| 192 | 240 | - Run project lint/typecheck/tests and fix failures. |
| 193 | 241 | |
| 194 | 242 | Deliverables: |
| 243 | ||
| 195 | 244 | - Green checks with canonical schema + persistence model in place. |
| 196 | 245 | |
| 197 | 246 | Breakage allowed: |
| 247 | ||
| 198 | 248 | - None at end of step. |
| 199 | 249 | |
| 200 | 250 | Exit criteria: |
| 251 | ||
| 201 | 252 | - SPEC `7.1`-`7.4` requirements satisfied and validated by tests. |
.plans/spec-contract-matrix.md+32| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | # SPEC Contract Matrix (Sections 7.1-7.4) |
| 2 | 2 | |
| 3 | 3 | Status legend: |
| 4 | ||
| 4 | 5 | - `required`: requirement acknowledged, no current implementation claim yet. |
| 5 | 6 | - `implemented`: requirement currently satisfied in code + schema. |
| 6 | 7 | - `to-replace`: partial/misaligned implementation exists and must be replaced. |
| ... | ... | @@ -9,6 +10,7 @@ Status legend: |
| 9 | 10 | ## 7.1 Write-Side Persisted Tables |
| 10 | 11 | |
| 11 | 12 | ### W1 |
| 13 | ||
| 12 | 14 | - Spec ref: `7.1.1 orchestration_events` |
| 13 | 15 | - Requirement: append-only event store with canonical envelope columns. |
| 14 | 16 | - SQL contract: |
| ... | ... | @@ -32,6 +34,7 @@ Status legend: |
| 32 | 34 | - Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. |
| 33 | 35 | |
| 34 | 36 | ### W2 |
| 37 | ||
| 35 | 38 | - Spec ref: `7.1.2 orchestration_command_receipts` |
| 36 | 39 | - Requirement: command idempotency + ack replay receipts table. |
| 37 | 40 | - SQL contract: |
| ... | ... | @@ -49,6 +52,7 @@ Status legend: |
| 49 | 52 | - Notes: missing table and missing idempotency flow. |
| 50 | 53 | |
| 51 | 54 | ### W3 |
| 55 | ||
| 52 | 56 | - Spec ref: `7.1.3 checkpoint_diff_blobs` |
| 53 | 57 | - Requirement: store large plaintext diffs separate from checkpoint summaries. |
| 54 | 58 | - SQL contract: |
| ... | ... | @@ -65,6 +69,7 @@ Status legend: |
| 65 | 69 | - Notes: no canonical diff blob table yet. |
| 66 | 70 | |
| 67 | 71 | ### W4 |
| 72 | ||
| 68 | 73 | - Spec ref: `7.1.4 provider_session_runtime` |
| 69 | 74 | - Requirement: server-internal provider runtime/resume state. |
| 70 | 75 | - SQL contract: |
| ... | ... | @@ -86,6 +91,7 @@ Status legend: |
| 86 | 91 | ## 7.2 Canonical Persisted Event Schema |
| 87 | 92 | |
| 88 | 93 | ### E1 |
| 94 | ||
| 89 | 95 | - Spec ref: `7.2 OrchestrationPersistedEventSchema` |
| 90 | 96 | - Requirement: full typed persisted event envelope in shared contracts. |
| 91 | 97 | - SQL contract: envelope fields in W1 must map 1:1 to contracts schema. |
| ... | ... | @@ -96,6 +102,7 @@ Status legend: |
| 96 | 102 | - Notes: contract schema exists; DB + store mapping still incomplete. |
| 97 | 103 | |
| 98 | 104 | ### E2 |
| 105 | ||
| 99 | 106 | - Spec ref: `7.2 Rules/payload discriminated by eventType` |
| 100 | 107 | - Requirement: `payload` validation keyed by `eventType`. |
| 101 | 108 | - SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. |
| ... | ... | @@ -106,6 +113,7 @@ Status legend: |
| 106 | 113 | - Notes: decode is present but DB does not persist full envelope columns. |
| 107 | 114 | |
| 108 | 115 | ### E3 |
| 116 | ||
| 109 | 117 | - Spec ref: `7.2 Rules/provider ids scope` |
| 110 | 118 | - Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. |
| 111 | 119 | - SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. |
| ... | ... | @@ -116,6 +124,7 @@ Status legend: |
| 116 | 124 | - Notes: metadata plumbing is incomplete in persistence path. |
| 117 | 125 | |
| 118 | 126 | ### E4 |
| 127 | ||
| 119 | 128 | - Spec ref: `7.2 Rules/streamVersion concurrency guard` |
| 120 | 129 | - Requirement: stream version monotonic per aggregate stream; enforced on write. |
| 121 | 130 | - SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. |
| ... | ... | @@ -128,6 +137,7 @@ Status legend: |
| 128 | 137 | ## 7.3 Required Projected Tables (Read Models) |
| 129 | 138 | |
| 130 | 139 | ### P1 |
| 140 | ||
| 131 | 141 | - Spec ref: `7.3.1 projection_projects` |
| 132 | 142 | - Requirement: persisted project projection table. |
| 133 | 143 | - SQL contract: |
| ... | ... | @@ -145,6 +155,7 @@ Status legend: |
| 145 | 155 | - Notes: legacy `projects` table is separate concept and should be removed from orchestration model. |
| 146 | 156 | |
| 147 | 157 | ### P2 |
| 158 | ||
| 148 | 159 | - Spec ref: `7.3.2 projection_threads` |
| 149 | 160 | - Requirement: persisted thread projection table. |
| 150 | 161 | - SQL contract: |
| ... | ... | @@ -165,6 +176,7 @@ Status legend: |
| 165 | 176 | - Notes: missing table and projector writes. |
| 166 | 177 | |
| 167 | 178 | ### P3 |
| 179 | ||
| 168 | 180 | - Spec ref: `7.3.3 projection_thread_messages` |
| 169 | 181 | - Requirement: persisted thread message projection table. |
| 170 | 182 | - SQL contract: |
| ... | ... | @@ -183,6 +195,7 @@ Status legend: |
| 183 | 195 | - Notes: missing table and message projection writes. |
| 184 | 196 | |
| 185 | 197 | ### P4 |
| 198 | ||
| 186 | 199 | - Spec ref: `7.3.4 projection_thread_activities` |
| 187 | 200 | - Requirement: persisted thread activity projection table. |
| 188 | 201 | - SQL contract: |
| ... | ... | @@ -201,6 +214,7 @@ Status legend: |
| 201 | 214 | - Notes: no canonical activity projection persistence. |
| 202 | 215 | |
| 203 | 216 | ### P5 |
| 217 | ||
| 204 | 218 | - Spec ref: `7.3.5 projection_thread_sessions` |
| 205 | 219 | - Requirement: persisted thread session projection table. |
| 206 | 220 | - SQL contract: |
| ... | ... | @@ -219,6 +233,7 @@ Status legend: |
| 219 | 233 | - Notes: current provider session table is not this domain projection. |
| 220 | 234 | |
| 221 | 235 | ### P6 |
| 236 | ||
| 222 | 237 | - Spec ref: `7.3.6 projection_thread_turns` |
| 223 | 238 | - Requirement: persisted thread turn projection table. |
| 224 | 239 | - SQL contract: |
| ... | ... | @@ -237,6 +252,7 @@ Status legend: |
| 237 | 252 | - Notes: missing table and projection logic. |
| 238 | 253 | |
| 239 | 254 | ### P7 |
| 255 | ||
| 240 | 256 | - Spec ref: `7.3.7 projection_checkpoints` |
| 241 | 257 | - Requirement: persisted checkpoint summary projection table. |
| 242 | 258 | - SQL contract: |
| ... | ... | @@ -256,6 +272,7 @@ Status legend: |
| 256 | 272 | - Notes: current table semantics do not match canonical checkpoint projection schema. |
| 257 | 273 | |
| 258 | 274 | ### P8 |
| 275 | ||
| 259 | 276 | - Spec ref: `7.3.8 projection_pending_approvals` |
| 260 | 277 | - Requirement: persisted pending-approval projection table. |
| 261 | 278 | - SQL contract: |
| ... | ... | @@ -273,6 +290,7 @@ Status legend: |
| 273 | 290 | - Notes: missing table and projection logic. |
| 274 | 291 | |
| 275 | 292 | ### P9 |
| 293 | ||
| 276 | 294 | - Spec ref: `7.3.9 projection_state` |
| 277 | 295 | - Requirement: projector progress tracking table. |
| 278 | 296 | - SQL contract: |
| ... | ... | @@ -286,6 +304,7 @@ Status legend: |
| 286 | 304 | - Notes: missing table and projector bookkeeping. |
| 287 | 305 | |
| 288 | 306 | ### P10 |
| 307 | ||
| 289 | 308 | - Spec ref: `7.3 Projection consistency rules` |
| 290 | 309 | - Requirement: projector row updates and `projection_state` update must be atomic per event. |
| 291 | 310 | - SQL contract: per-projector transaction boundary covering both projection write and state update. |
| ... | ... | @@ -296,6 +315,7 @@ Status legend: |
| 296 | 315 | - Notes: requires transactional projection executor. |
| 297 | 316 | |
| 298 | 317 | ### P11 |
| 318 | ||
| 299 | 319 | - Spec ref: `7.3 Optional debug field` |
| 300 | 320 | - Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. |
| 301 | 321 | - SQL contract: optional; not required in baseline schema. |
| ... | ... | @@ -308,6 +328,7 @@ Status legend: |
| 308 | 328 | ## 7.4 Snapshot and RPC Requirements |
| 309 | 329 | |
| 310 | 330 | ### R1 |
| 331 | ||
| 311 | 332 | - Spec ref: `7.4.1` |
| 312 | 333 | - Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. |
| 313 | 334 | - SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. |
| ... | ... | @@ -318,6 +339,7 @@ Status legend: |
| 318 | 339 | - Notes: current in-memory read model path must be removed for SPEC compliance. |
| 319 | 340 | |
| 320 | 341 | ### R2 |
| 342 | ||
| 321 | 343 | - Spec ref: `7.4.2` |
| 322 | 344 | - Requirement: snapshot `projects[]` source is `projection_projects`. |
| 323 | 345 | - SQL contract: `projects` collection assembled from `projection_projects` rows. |
| ... | ... | @@ -328,6 +350,7 @@ Status legend: |
| 328 | 350 | - Notes: no DB project projection reader exists yet. |
| 329 | 351 | |
| 330 | 352 | ### R3 |
| 353 | ||
| 331 | 354 | - Spec ref: `7.4.3` |
| 332 | 355 | - Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. |
| 333 | 356 | - SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. |
| ... | ... | @@ -338,6 +361,7 @@ Status legend: |
| 338 | 361 | - Notes: canonical projection table and reader not implemented. |
| 339 | 362 | |
| 340 | 363 | ### R4 |
| 364 | ||
| 341 | 365 | - Spec ref: `7.4.4` |
| 342 | 366 | - Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. |
| 343 | 367 | - SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. |
| ... | ... | @@ -348,6 +372,7 @@ Status legend: |
| 348 | 372 | - Notes: current checkpoint repository is not canonical source. |
| 349 | 373 | |
| 350 | 374 | ### R5 |
| 375 | ||
| 351 | 376 | - Spec ref: `7.4.5` |
| 352 | 377 | - Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. |
| 353 | 378 | - SQL contract: session lookup by `thread_id` from projection table. |
| ... | ... | @@ -358,6 +383,7 @@ Status legend: |
| 358 | 383 | - Notes: remove provider-session-as-routing-key behavior. |
| 359 | 384 | |
| 360 | 385 | ### R6 |
| 386 | ||
| 361 | 387 | - Spec ref: `7.4.6` |
| 362 | 388 | - Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. |
| 363 | 389 | - SQL contract: `MIN(last_applied_sequence)` across required projector keys. |
| ... | ... | @@ -368,6 +394,7 @@ Status legend: |
| 368 | 394 | - Notes: must move from in-memory sequence to DB projection-state semantics. |
| 369 | 395 | |
| 370 | 396 | ### R7 |
| 397 | ||
| 371 | 398 | - Spec ref: `7.4.7` |
| 372 | 399 | - Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). |
| 373 | 400 | - SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. |
| ... | ... | @@ -380,22 +407,27 @@ Status legend: |
| 380 | 407 | ## Ambiguous/Interpretation Decisions (tracked upfront) |
| 381 | 408 | |
| 382 | 409 | ### A1 |
| 410 | ||
| 383 | 411 | - Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. |
| 384 | 412 | - Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. |
| 385 | 413 | |
| 386 | 414 | ### A2 |
| 415 | ||
| 387 | 416 | - Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. |
| 388 | 417 | - Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. |
| 389 | 418 | |
| 390 | 419 | ### A3 |
| 420 | ||
| 391 | 421 | - Topic: `snapshotSequence` dependency set for min-sequence computation. |
| 392 | 422 | - Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). |
| 393 | 423 | |
| 394 | 424 | ### A4 |
| 425 | ||
| 395 | 426 | - Topic: no-gap handoff mechanism in `7.4.7`. |
| 396 | 427 | - Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. |
| 397 | 428 | |
| 398 | 429 | ## Checklist Completeness Statement |
| 430 | ||
| 399 | 431 | - Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. |
| 400 | 432 | - Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. |
| 401 | 433 | - Unclassified rows: `0`. |
apps/desktop/scripts/dev-electron.mjs+15-7| ... | ... | @@ -7,7 +7,11 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 7 | 7 | |
| 8 | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); |
| 9 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; | |
| 10 | const requiredFiles = [ | |
| 11 | "dist-electron/main.js", | |
| 12 | "dist-electron/preload.js", | |
| 13 | "../server/dist/index.mjs", | |
| 14 | ]; | |
| 11 | 15 | const watchedDirectories = [ |
| 12 | 16 | { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, |
| 13 | 17 | { directory: "../server/dist", files: new Set(["index.mjs"]) }, |
| ... | ... | @@ -148,13 +152,17 @@ function scheduleRestart() { |
| 148 | 152 | |
| 149 | 153 | function startWatchers() { |
| 150 | 154 | 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 | } | |
| 155 | 162 | |
| 156 | scheduleRestart(); | |
| 157 | }); | |
| 163 | scheduleRestart(); | |
| 164 | }, | |
| 165 | ); | |
| 158 | 166 | |
| 159 | 167 | watchers.push(watcher); |
| 160 | 168 | } |
apps/desktop/src/main.ts+12-9| ... | ... | @@ -15,10 +15,7 @@ import { NetService } from "@t3tools/shared/Net"; |
| 15 | 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 16 | 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 17 | 17 | import { fixPath } from "./fixPath"; |
| 18 | import { | |
| 19 | getAutoUpdateDisabledReason, | |
| 20 | shouldBroadcastDownloadProgress, | |
| 21 | } from "./updateState"; | |
| 18 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | |
| 22 | 19 | import { |
| 23 | 20 | createInitialDesktopUpdateState, |
| 24 | 21 | reduceDesktopUpdateStateOnCheckFailure, |
| ... | ... | @@ -702,7 +699,9 @@ async function checkForUpdates(reason: string): Promise<void> { |
| 702 | 699 | await autoUpdater.checkForUpdates(); |
| 703 | 700 | } catch (error: unknown) { |
| 704 | 701 | 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 | ); | |
| 706 | 705 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); |
| 707 | 706 | } finally { |
| 708 | 707 | updateCheckInFlight = false; |
| ... | ... | @@ -764,9 +763,7 @@ function configureAutoUpdater(): void { |
| 764 | 763 | updaterConfigured = true; |
| 765 | 764 | |
| 766 | 765 | 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() || ""; | |
| 770 | 767 | if (githubToken) { |
| 771 | 768 | // When a token is provided, re-configure the feed with `private: true` so |
| 772 | 769 | // electron-updater uses the GitHub API (api.github.com) instead of the |
| ... | ... | @@ -801,7 +798,13 @@ function configureAutoUpdater(): void { |
| 801 | 798 | console.info("[desktop-updater] Looking for updates..."); |
| 802 | 799 | }); |
| 803 | 800 | 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 | ); | |
| 805 | 808 | lastLoggedDownloadMilestone = -1; |
| 806 | 809 | console.info(`[desktop-updater] Update available: ${info.version}`); |
| 807 | 810 | }); |
apps/desktop/src/runtimeArch.ts+1-2| ... | ... | @@ -25,8 +25,7 @@ export function resolveDesktopRuntimeInfo( |
| 25 | 25 | }; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | const hostArch = | |
| 29 | appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | |
| 28 | const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | |
| 30 | 29 | |
| 31 | 30 | return { |
| 32 | 31 | hostArch, |
apps/desktop/src/updateMachine.test.ts+4-1| ... | ... | @@ -83,7 +83,10 @@ describe("updateMachine", () => { |
| 83 | 83 | }, |
| 84 | 84 | "1.1.0", |
| 85 | 85 | ); |
| 86 | const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out"); | |
| 86 | const failedInstall = reduceDesktopUpdateStateOnInstallFailure( | |
| 87 | downloaded, | |
| 88 | "backend shutdown timed out", | |
| 89 | ); | |
| 87 | 90 | |
| 88 | 91 | expect(downloaded.status).toBe("downloaded"); |
| 89 | 92 | 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 |
| 72 | 72 | return "completed"; |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | function mapRequestType(requestKind: unknown): | |
| 76 | | "command_execution_approval" | |
| 77 | | "file_change_approval" | |
| 78 | | "unknown" { | |
| 75 | function mapRequestType( | |
| 76 | requestKind: unknown, | |
| 77 | ): "command_execution_approval" | "file_change_approval" | "unknown" { | |
| 79 | 78 | if (requestKind === "command") { |
| 80 | 79 | return "command_execution_approval"; |
| 81 | 80 | } |
| ... | ... | @@ -239,323 +238,323 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 239 | 238 | }> |
| 240 | 239 | >(); |
| 241 | 240 | |
| 242 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); | |
| 241 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); | |
| 243 | 242 | |
| 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 | } | |
| 253 | 252 | |
| 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, | |
| 272 | 261 | 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 | }; | |
| 279 | 267 | |
| 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 | }); | |
| 282 | 278 | |
| 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 | }); | |
| 289 | 281 | |
| 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 | } | |
| 293 | 288 | |
| 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}`); | |
| 302 | 292 | |
| 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 | }); | |
| 316 | 300 | } |
| 317 | 301 | |
| 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 | } | |
| 324 | 329 | } |
| 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; | |
| 329 | 333 | } |
| 334 | ||
| 335 | yield* emit(runtimeEvent); | |
| 330 | 336 | } |
| 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 }); | |
| 334 | 340 | } |
| 335 | 341 | |
| 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 | }; | |
| 338 | 356 | |
| 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 | }; | |
| 342 | 361 | |
| 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 { | |
| 369 | 381 | threadId: state.snapshot.threadId, |
| 370 | 382 | 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 | ); | |
| 379 | 456 | } |
| 380 | 457 | |
| 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 | ), | |
| 456 | 505 | ); |
| 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"; |
| 6 | 6 | import { Command, Flag } from "effect/unstable/cli"; |
| 7 | 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | 8 | |
| 9 | import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; | |
| 9 | import { | |
| 10 | DEVELOPMENT_ICON_OVERRIDES, | |
| 11 | PUBLISH_ICON_OVERRIDES, | |
| 12 | } from "../../../scripts/lib/brand-assets.ts"; | |
| 10 | 13 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; |
| 11 | 14 | import rootPackageJson from "../../../package.json" with { type: "json" }; |
| 12 | 15 | import serverPackageJson from "../package.json" with { type: "json" }; |
| ... | ... | @@ -237,9 +240,7 @@ const publishCmd = Command.make( |
| 237 | 240 | Effect.gen(function* () { |
| 238 | 241 | yield* restorePublishIconOverrides(resource.iconBackups).pipe( |
| 239 | 242 | 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)}`), | |
| 243 | 244 | ), |
| 244 | 245 | ); |
| 245 | 246 | 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" |
| 12 | 12 | const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; |
| 13 | 13 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; |
| 14 | 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 15 | const 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}"; | |
| 15 | const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; | |
| 17 | 16 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 18 | 17 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, |
| 19 | 18 | "i", |
apps/server/src/codexAppServerManager.test.ts+60-66| ... | ... | @@ -749,88 +749,82 @@ describe("respondToUserInput", () => { |
| 749 | 749 | }); |
| 750 | 750 | |
| 751 | 751 | describe.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"); | |
| 757 | 755 | |
| 758 | const manager = new CodexAppServerManager(); | |
| 756 | const manager = new CodexAppServerManager(); | |
| 759 | 757 | |
| 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 } : {}), | |
| 775 | 768 | }, |
| 776 | }); | |
| 769 | }, | |
| 770 | }); | |
| 777 | 771 | |
| 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 | }); | |
| 782 | 776 | |
| 783 | expect(firstTurn.threadId).toBe(firstSession.threadId); | |
| 777 | expect(firstTurn.threadId).toBe(firstSession.threadId); | |
| 784 | 778 | |
| 785 | await vi.waitFor(async () => { | |
| 779 | await vi.waitFor( | |
| 780 | async () => { | |
| 786 | 781 | const snapshot = await manager.readThread(firstSession.threadId); |
| 787 | 782 | expect(snapshot.turns.length).toBeGreaterThan(0); |
| 788 | }, { timeout: 120_000, interval: 1_000 }); | |
| 783 | }, | |
| 784 | { timeout: 120_000, interval: 1_000 }, | |
| 785 | ); | |
| 789 | 786 | |
| 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; | |
| 793 | 790 | |
| 794 | manager.stopSession(firstSession.threadId); | |
| 791 | manager.stopSession(firstSession.threadId); | |
| 795 | 792 | |
| 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 } : {}), | |
| 811 | 803 | }, |
| 812 | }); | |
| 804 | }, | |
| 805 | }); | |
| 813 | 806 | |
| 814 | expect(resumedSession.threadId).toBe(originalThreadId); | |
| 807 | expect(resumedSession.threadId).toBe(originalThreadId); | |
| 815 | 808 | |
| 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); | |
| 819 | 812 | |
| 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 | }); | |
| 824 | 817 | |
| 825 | await vi.waitFor(async () => { | |
| 818 | await vi.waitFor( | |
| 819 | async () => { | |
| 826 | 820 | const snapshot = await manager.readThread(resumedSession.threadId); |
| 827 | 821 | 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); | |
| 836 | 830 | }); |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+2-5| ... | ... | @@ -363,8 +363,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 363 | 363 | Effect.gen(function* () { |
| 364 | 364 | const fs = yield* FileSystem.FileSystem; |
| 365 | 365 | 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)}`; | |
| 368 | 367 | const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); |
| 369 | 368 | yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); |
| 370 | 369 | yield* fs.writeFile(imagePath, Buffer.from("hello")); |
| ... | ... | @@ -392,9 +391,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 392 | 391 | }), |
| 393 | 392 | ), |
| 394 | 393 | ), |
| 395 | Effect.ensuring( | |
| 396 | fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), | |
| 397 | ), | |
| 394 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), | |
| 398 | 395 | ); |
| 399 | 396 | |
| 400 | 397 | 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]) |
| 153 | 153 | }); |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | function fetchGitPullRequestBranch( | |
| 157 | input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0], | |
| 158 | ) { | |
| 156 | function fetchGitPullRequestBranch(input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0]) { | |
| 159 | 157 | return Effect.gen(function* () { |
| 160 | 158 | const core = yield* GitCore; |
| 161 | 159 | return yield* core.fetchPullRequestBranch(input); |
| ... | ... | @@ -441,7 +439,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 441 | 439 | true, |
| 442 | 440 | ); |
| 443 | 441 | 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 | ), | |
| 445 | 445 | ).toBe(true); |
| 446 | 446 | expect( |
| 447 | 447 | result.branches.some( |
| ... | ... | @@ -704,29 +704,27 @@ it.layer(TestLayer)("git integration", (it) => { |
| 704 | 704 | }), |
| 705 | 705 | ); |
| 706 | 706 | |
| 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"]); | |
| 714 | 712 | |
| 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]); | |
| 721 | 719 | |
| 722 | yield* createGitBranch({ cwd: source, branch: "feature" }); | |
| 720 | yield* createGitBranch({ cwd: source, branch: "feature" }); | |
| 723 | 721 | |
| 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 | }), | |
| 730 | 728 | ); |
| 731 | 729 | |
| 732 | 730 | it.effect("checks out a remote tracking branch when remote name contains slashes", () => |
| ... | ... | @@ -955,13 +953,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 955 | 953 | }); |
| 956 | 954 | |
| 957 | 955 | 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"]); | |
| 965 | 957 | }), |
| 966 | 958 | ); |
| 967 | 959 | }); |
| ... | ... | @@ -1325,23 +1317,25 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1325 | 1317 | }), |
| 1326 | 1318 | ); |
| 1327 | 1319 | |
| 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; | |
| 1333 | 1327 | |
| 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"]); | |
| 1335 | 1329 | |
| 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 | }); | |
| 1341 | 1335 | |
| 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 | }), | |
| 1345 | 1339 | ); |
| 1346 | 1340 | |
| 1347 | 1341 | 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> { |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | 103 | function 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, ""); | |
| 105 | 108 | return sanitized.length > 0 ? sanitized : "fork"; |
| 106 | 109 | } |
| 107 | 110 | |
| 108 | 111 | function 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(); | |
| 110 | 117 | } |
| 111 | 118 | |
| 112 | 119 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { |
| ... | ... | @@ -470,10 +477,11 @@ const makeGitCore = Effect.gen(function* () { |
| 470 | 477 | Effect.gen(function* () { |
| 471 | 478 | const preferredName = sanitizeRemoteName(input.preferredName); |
| 472 | 479 | 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))); | |
| 477 | 485 | |
| 478 | 486 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { |
| 479 | 487 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { |
| ... | ... | @@ -488,7 +496,12 @@ const makeGitCore = Effect.gen(function* () { |
| 488 | 496 | suffix += 1; |
| 489 | 497 | } |
| 490 | 498 | |
| 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 | ]); | |
| 492 | 505 | return remoteName; |
| 493 | 506 | }); |
| 494 | 507 | |
| ... | ... | @@ -1099,14 +1112,9 @@ const makeGitCore = Effect.gen(function* () { |
| 1099 | 1112 | ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] |
| 1100 | 1113 | : ["worktree", "add", worktreePath, input.branch]; |
| 1101 | 1114 | |
| 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 | }); | |
| 1110 | 1118 | |
| 1111 | 1119 | return { |
| 1112 | 1120 | worktree: { |
apps/server/src/git/Layers/GitManager.test.ts+137-125| ... | ... | @@ -120,7 +120,11 @@ function runGit( |
| 120 | 120 | |
| 121 | 121 | function initRepo( |
| 122 | 122 | 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 | > { | |
| 124 | 128 | return Effect.gen(function* () { |
| 125 | 129 | const fs = yield* FileSystem.FileSystem; |
| 126 | 130 | yield* runGit(cwd, ["init", "--initial-branch=main"]); |
| ... | ... | @@ -150,9 +154,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 150 | 154 | Effect.succeed({ |
| 151 | 155 | subject: "Implement stacked git actions", |
| 152 | 156 | body: "", |
| 153 | ...(input.includeBranch | |
| 154 | ? { branch: "feature/implement-stacked-git-actions" } | |
| 155 | : {}), | |
| 157 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | |
| 156 | 158 | }), |
| 157 | 159 | generatePrContent: () => |
| 158 | 160 | Effect.succeed({ |
| ... | ... | @@ -416,9 +418,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 416 | 418 | "--json", |
| 417 | 419 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 418 | 420 | ], |
| 419 | }).pipe( | |
| 420 | Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary), | |
| 421 | ), | |
| 421 | }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), | |
| 422 | 422 | getRepositoryCloneUrls: (input) => |
| 423 | 423 | execute({ |
| 424 | 424 | cwd: input.cwd, |
| ... | ... | @@ -427,12 +427,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 427 | 427 | checkoutPullRequest: (input) => |
| 428 | 428 | execute({ |
| 429 | 429 | 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"] : [])], | |
| 436 | 431 | }).pipe(Effect.asVoid), |
| 437 | 432 | }, |
| 438 | 433 | ghCalls, |
| ... | ... | @@ -722,9 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 722 | 717 | return { |
| 723 | 718 | subject: "Implement stacked git actions", |
| 724 | 719 | body: "", |
| 725 | ...(input.includeBranch | |
| 726 | ? { branch: "feature/implement-stacked-git-actions" } | |
| 727 | : {}), | |
| 720 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | |
| 728 | 721 | }; |
| 729 | 722 | }), |
| 730 | 723 | }, |
| ... | ... | @@ -1199,9 +1192,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1199 | 1192 | expect(result.branch).toBe("feature/pr-worktree"); |
| 1200 | 1193 | expect(result.worktreePath).not.toBeNull(); |
| 1201 | 1194 | 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(); | |
| 1205 | 1199 | expect(worktreeBranch).toBe("feature/pr-worktree"); |
| 1206 | 1200 | }), |
| 1207 | 1201 | ); |
| ... | ... | @@ -1251,15 +1245,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1251 | 1245 | }); |
| 1252 | 1246 | |
| 1253 | 1247 | 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(); | |
| 1257 | 1253 | expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); |
| 1258 | 1254 | expect(upstreamRef.startsWith("origin/")).toBe(false); |
| 1259 | 1255 | 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(), | |
| 1263 | 1261 | ).toBe(forkDir); |
| 1264 | 1262 | }), |
| 1265 | 1263 | ); |
| ... | ... | @@ -1330,7 +1328,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1330 | 1328 | fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); |
| 1331 | 1329 | yield* runGit(repoDir, ["add", "derived-fork.txt"]); |
| 1332 | 1330 | 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 | ]); | |
| 1334 | 1337 | yield* runGit(repoDir, ["checkout", "main"]); |
| 1335 | 1338 | yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); |
| 1336 | 1339 | |
| ... | ... | @@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1407 | 1410 | }), |
| 1408 | 1411 | ); |
| 1409 | 1412 | |
| 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 | }, | |
| 1444 | 1450 | }, |
| 1445 | 1451 | }, |
| 1446 | }, | |
| 1447 | }); | |
| 1452 | }); | |
| 1448 | 1453 | |
| 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 | }); | |
| 1454 | 1459 | |
| 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 | }), | |
| 1463 | 1471 | ); |
| 1464 | 1472 | |
| 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 | }, | |
| 1500 | 1511 | }, |
| 1501 | 1512 | }, |
| 1502 | }, | |
| 1503 | }); | |
| 1513 | }); | |
| 1504 | 1514 | |
| 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 | }); | |
| 1510 | 1520 | |
| 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 | }), | |
| 1519 | 1531 | ); |
| 1520 | 1532 | |
| 1521 | 1533 | 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* () { |
| 655 | 655 | }; |
| 656 | 656 | }); |
| 657 | 657 | |
| 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))); | |
| 667 | 666 | |
| 668 | return { pullRequest }; | |
| 669 | }); | |
| 667 | return { pullRequest }; | |
| 668 | }, | |
| 669 | ); | |
| 670 | 670 | |
| 671 | 671 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced( |
| 672 | 672 | function* (input) { |
| 673 | 673 | const normalizedReference = normalizePullRequestReference(input.reference); |
| 674 | 674 | 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 | }); | |
| 680 | 679 | const pullRequest = toResolvedPullRequest(pullRequestSummary); |
| 681 | 680 | |
| 682 | 681 | if (input.mode === "local") { |
| ... | ... | @@ -715,12 +714,11 @@ export const makeGitManager = Effect.gen(function* () { |
| 715 | 714 | }); |
| 716 | 715 | |
| 717 | 716 | 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); | |
| 724 | 722 | |
| 725 | 723 | const findLocalHeadBranch = (cwd: string) => |
| 726 | 724 | gitCore.listBranches({ cwd }).pipe( |
apps/server/src/git/Services/GitCore.ts+1-3| ... | ... | @@ -165,9 +165,7 @@ export interface GitCoreShape { |
| 165 | 165 | /** |
| 166 | 166 | * Ensure a named remote exists for the provided URL, returning the reused or created remote name. |
| 167 | 167 | */ |
| 168 | readonly ensureRemote: ( | |
| 169 | input: GitEnsureRemoteInput, | |
| 170 | ) => Effect.Effect<string, GitCommandError>; | |
| 168 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; | |
| 171 | 169 | |
| 172 | 170 | /** |
| 173 | 171 | * 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) => { |
| 211 | 211 | }); |
| 212 | 212 | |
| 213 | 213 | 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 | ); | |
| 215 | 217 | assert.deepEqual(configState.issues, [ |
| 216 | 218 | { |
| 217 | 219 | kind: "keybindings.invalid-entry", |
apps/server/src/open.ts+4-4| ... | ... | @@ -40,7 +40,9 @@ interface CommandAvailabilityOptions { |
| 40 | 40 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 41 | 41 | |
| 42 | 42 | function 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 | ); | |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | 48 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { |
| ... | ... | @@ -239,9 +241,7 @@ export const launchDetached = (launch: EditorLaunch) => |
| 239 | 241 | }); |
| 240 | 242 | } catch (error) { |
| 241 | 243 | 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 })), | |
| 245 | 245 | ); |
| 246 | 246 | } |
| 247 | 247 |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+11-11| ... | ... | @@ -69,7 +69,7 @@ function createProviderServiceHarness( |
| 69 | 69 | Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>; |
| 70 | 70 | const listSessions = () => |
| 71 | 71 | hasSession |
| 72 | ? Effect.succeed([ | |
| 72 | ? Effect.succeed([ | |
| 73 | 73 | { |
| 74 | 74 | provider: providerName, |
| 75 | 75 | status: "ready", |
| ... | ... | @@ -350,7 +350,7 @@ describe("CheckpointReactor", () => { |
| 350 | 350 | type: "turn.started", |
| 351 | 351 | eventId: EventId.makeUnsafe("evt-turn-started-1"), |
| 352 | 352 | provider: "codex", |
| 353 | ||
| 353 | ||
| 354 | 354 | createdAt: new Date().toISOString(), |
| 355 | 355 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 356 | 356 | turnId: asTurnId("turn-1"), |
| ... | ... | @@ -365,7 +365,7 @@ describe("CheckpointReactor", () => { |
| 365 | 365 | type: "turn.completed", |
| 366 | 366 | eventId: EventId.makeUnsafe("evt-turn-completed-1"), |
| 367 | 367 | provider: "codex", |
| 368 | ||
| 368 | ||
| 369 | 369 | createdAt: new Date().toISOString(), |
| 370 | 370 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 371 | 371 | turnId: asTurnId("turn-1"), |
| ... | ... | @@ -426,7 +426,7 @@ describe("CheckpointReactor", () => { |
| 426 | 426 | type: "turn.started", |
| 427 | 427 | eventId: EventId.makeUnsafe("evt-turn-started-main"), |
| 428 | 428 | provider: "codex", |
| 429 | ||
| 429 | ||
| 430 | 430 | createdAt: new Date().toISOString(), |
| 431 | 431 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 432 | 432 | turnId: asTurnId("turn-main"), |
| ... | ... | @@ -442,7 +442,7 @@ describe("CheckpointReactor", () => { |
| 442 | 442 | type: "turn.completed", |
| 443 | 443 | eventId: EventId.makeUnsafe("evt-turn-completed-aux"), |
| 444 | 444 | provider: "codex", |
| 445 | ||
| 445 | ||
| 446 | 446 | createdAt: new Date().toISOString(), |
| 447 | 447 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 448 | 448 | turnId: asTurnId("turn-aux"), |
| ... | ... | @@ -460,7 +460,7 @@ describe("CheckpointReactor", () => { |
| 460 | 460 | type: "turn.completed", |
| 461 | 461 | eventId: EventId.makeUnsafe("evt-turn-completed-main"), |
| 462 | 462 | provider: "codex", |
| 463 | ||
| 463 | ||
| 464 | 464 | createdAt: new Date().toISOString(), |
| 465 | 465 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 466 | 466 | turnId: asTurnId("turn-main"), |
| ... | ... | @@ -500,7 +500,7 @@ describe("CheckpointReactor", () => { |
| 500 | 500 | type: "turn.completed", |
| 501 | 501 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), |
| 502 | 502 | provider: "codex", |
| 503 | ||
| 503 | ||
| 504 | 504 | createdAt: new Date().toISOString(), |
| 505 | 505 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 506 | 506 | turnId: asTurnId("turn-missing-baseline"), |
| ... | ... | @@ -589,7 +589,7 @@ describe("CheckpointReactor", () => { |
| 589 | 589 | type: "turn.completed", |
| 590 | 590 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), |
| 591 | 591 | provider: "codex", |
| 592 | ||
| 592 | ||
| 593 | 593 | createdAt: new Date().toISOString(), |
| 594 | 594 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 595 | 595 | turnId: asTurnId("turn-missing-cwd"), |
| ... | ... | @@ -635,7 +635,7 @@ describe("CheckpointReactor", () => { |
| 635 | 635 | type: "checkpoint.captured", |
| 636 | 636 | eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), |
| 637 | 637 | provider: "codex", |
| 638 | ||
| 638 | ||
| 639 | 639 | createdAt: new Date().toISOString(), |
| 640 | 640 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 641 | 641 | turnId: asTurnId("turn-3"), |
| ... | ... | @@ -685,7 +685,7 @@ describe("CheckpointReactor", () => { |
| 685 | 685 | type: "turn.completed", |
| 686 | 686 | eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), |
| 687 | 687 | provider: "codex", |
| 688 | ||
| 688 | ||
| 689 | 689 | createdAt: new Date().toISOString(), |
| 690 | 690 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 691 | 691 | turnId: asTurnId("turn-runtime-failure"), |
| ... | ... | @@ -696,7 +696,7 @@ describe("CheckpointReactor", () => { |
| 696 | 696 | type: "turn.started", |
| 697 | 697 | eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), |
| 698 | 698 | provider: "codex", |
| 699 | ||
| 699 | ||
| 700 | 700 | createdAt: new Date().toISOString(), |
| 701 | 701 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 702 | 702 | turnId: asTurnId("turn-after-runtime-failure"), |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-9| ... | ... | @@ -115,9 +115,7 @@ const make = Effect.gen(function* () { |
| 115 | 115 | |
| 116 | 116 | const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( |
| 117 | 117 | 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 }>> { | |
| 121 | 119 | const readModel = yield* orchestrationEngine.getReadModel(); |
| 122 | 120 | const thread = readModel.threads.find((entry) => entry.id === threadId); |
| 123 | 121 | |
| ... | ... | @@ -133,9 +131,7 @@ const make = Effect.gen(function* () { |
| 133 | 131 | }; |
| 134 | 132 | |
| 135 | 133 | if (thread) { |
| 136 | const projectedSession = sessions.find( | |
| 137 | (session) => session.threadId === thread.id, | |
| 138 | ); | |
| 134 | const projectedSession = sessions.find((session) => session.threadId === thread.id); | |
| 139 | 135 | const fromProjected = findSessionWithCwd(projectedSession); |
| 140 | 136 | if (Option.isSome(fromProjected)) { |
| 141 | 137 | return fromProjected; |
| ... | ... | @@ -306,9 +302,7 @@ const make = Effect.gen(function* () { |
| 306 | 302 | } |
| 307 | 303 | |
| 308 | 304 | 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); | |
| 312 | 306 | if (!thread) { |
| 313 | 307 | return; |
| 314 | 308 | } |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+407-415| ... | ... | @@ -54,9 +54,7 @@ const runWithProjectionPipelineLayer = <A, E>( |
| 54 | 54 | (runtime) => Effect.promise(() => runtime.dispose()), |
| 55 | 55 | ); |
| 56 | 56 | |
| 57 | const projectionLayer = it.layer( | |
| 58 | makeProjectionPipelineTestLayer(process.cwd()), | |
| 59 | ); | |
| 57 | const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd())); | |
| 60 | 58 | |
| 61 | 59 | projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 62 | 60 | it.effect("bootstraps all projection states and writes projection rows", () => |
| ... | ... | @@ -450,153 +448,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 450 | 448 | }), |
| 451 | 449 | ); |
| 452 | 450 | |
| 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(); | |
| 487 | 462 | |
| 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 | }); | |
| 510 | 483 | |
| 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 | }); | |
| 541 | 506 | |
| 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 | }); | |
| 572 | 537 | |
| 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; | |
| 574 | 570 | |
| 575 | const rows = yield* sql<{ | |
| 576 | readonly attachmentsJson: string | null; | |
| 577 | }>` | |
| 571 | const rows = yield* sql<{ | |
| 572 | readonly attachmentsJson: string | null; | |
| 573 | }>` | |
| 578 | 574 | SELECT attachments_json AS "attachmentsJson" |
| 579 | 575 | FROM projection_thread_messages |
| 580 | 576 | WHERE message_id = 'message-overwrite' |
| 581 | 577 | `; |
| 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 }))), | |
| 598 | 591 | ), |
| 599 | 592 | ), |
| 593 | ), | |
| 600 | 594 | ); |
| 601 | 595 | |
| 602 | 596 | it.effect("does not persist attachment files when projector transaction rolls back", () => |
| ... | ... | @@ -711,11 +705,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 711 | 705 | `; |
| 712 | 706 | assert.equal(rows[0]?.count ?? 0, 0); |
| 713 | 707 | |
| 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"); | |
| 719 | 709 | assert.equal(fs.existsSync(attachmentPath), false); |
| 720 | 710 | yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; |
| 721 | 711 | }).pipe( |
| ... | ... | @@ -747,189 +737,189 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 747 | 737 | .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); |
| 748 | 738 | |
| 749 | 739 | 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 | }); | |
| 769 | 759 | |
| 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 | }); | |
| 792 | 782 | |
| 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 | }); | |
| 814 | 806 | |
| 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, | |
| 837 | 835 | }, |
| 838 | ], | |
| 839 | turnId: TurnId.makeUnsafe("turn-keep"), | |
| 840 | streaming: false, | |
| 841 | createdAt: now, | |
| 842 | updatedAt: now, | |
| 843 | }, | |
| 844 | }); | |
| 836 | }); | |
| 845 | 837 | |
| 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 | }); | |
| 867 | 861 | |
| 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, | |
| 890 | 890 | }, |
| 891 | ], | |
| 892 | turnId: TurnId.makeUnsafe("turn-remove"), | |
| 893 | streaming: false, | |
| 894 | createdAt: now, | |
| 895 | updatedAt: now, | |
| 896 | }, | |
| 897 | }); | |
| 891 | }); | |
| 898 | 892 | |
| 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); | |
| 917 | 907 | |
| 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 | }); | |
| 933 | 923 | |
| 934 | 924 | assert.equal(fs.existsSync(keepPath), true); |
| 935 | 925 | assert.equal(fs.existsSync(removePath), false); |
| ... | ... | @@ -962,107 +952,107 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 962 | 952 | .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); |
| 963 | 953 | |
| 964 | 954 | 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 | }); | |
| 984 | 974 | |
| 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 | }); | |
| 1007 | 997 | |
| 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, | |
| 1030 | 1026 | }, |
| 1031 | ], | |
| 1032 | turnId: null, | |
| 1033 | streaming: false, | |
| 1034 | createdAt: now, | |
| 1035 | updatedAt: now, | |
| 1036 | }, | |
| 1037 | }); | |
| 1027 | }); | |
| 1038 | 1028 | |
| 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); | |
| 1050 | 1040 | |
| 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 | }); | |
| 1066 | 1056 | |
| 1067 | 1057 | assert.equal(fs.existsSync(threadAttachmentPath), false); |
| 1068 | 1058 | assert.equal(fs.existsSync(otherThreadAttachmentPath), true); |
| ... | ... | @@ -1075,7 +1065,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1075 | 1065 | ); |
| 1076 | 1066 | |
| 1077 | 1067 | 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( | |
| 1079 | 1071 | Effect.flatMap((stateDir) => |
| 1080 | 1072 | Effect.gen(function* () { |
| 1081 | 1073 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-4| ... | ... | @@ -78,9 +78,8 @@ interface AttachmentSideEffects { |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | 80 | const 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), | |
| 84 | 83 | ); |
| 85 | 84 | |
| 86 | 85 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| ... | ... | @@ -336,7 +335,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid |
| 336 | 335 | }, |
| 337 | 336 | { concurrency: 1 }, |
| 338 | 337 | ); |
| 339 | ||
| 340 | 338 | }); |
| 341 | 339 | |
| 342 | 340 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1-8| ... | ... | @@ -1,11 +1,4 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | } from "@t3tools/contracts"; | |
| 1 | import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 9 | 2 | import { assert, it } from "@effect/vitest"; |
| 10 | 3 | import { Effect, Layer } from "effect"; |
| 11 | 4 | import * 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 => |
| 41 | 41 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 42 | 42 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 43 | 43 | |
| 44 | async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2000): Promise<void> { | |
| 44 | async function waitFor( | |
| 45 | predicate: () => boolean | Promise<boolean>, | |
| 46 | timeoutMs = 2000, | |
| 47 | ): Promise<void> { | |
| 45 | 48 | const deadline = Date.now() + timeoutMs; |
| 46 | 49 | const poll = async (): Promise<void> => { |
| 47 | 50 | if (await predicate()) { |
| ... | ... | @@ -101,7 +104,10 @@ describe("ProviderCommandReactor", () => { |
| 101 | 104 | ? input.resumeCursor |
| 102 | 105 | : undefined; |
| 103 | 106 | 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" | |
| 105 | 111 | ? input.model |
| 106 | 112 | : undefined; |
| 107 | 113 | const threadId = |
| ... | ... | @@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => { |
| 474 | 480 | |
| 475 | 481 | await waitFor(async () => { |
| 476 | 482 | 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 | ); | |
| 478 | 486 | return thread?.runtimeMode === "approval-required"; |
| 479 | 487 | }); |
| 480 | 488 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| ... | ... | @@ -563,7 +571,9 @@ describe("ProviderCommandReactor", () => { |
| 563 | 571 | |
| 564 | 572 | await waitFor(async () => { |
| 565 | 573 | 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 | ); | |
| 567 | 577 | return thread?.runtimeMode === "approval-required"; |
| 568 | 578 | }); |
| 569 | 579 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| ... | ... | @@ -767,9 +777,13 @@ describe("ProviderCommandReactor", () => { |
| 767 | 777 | |
| 768 | 778 | await waitFor(async () => { |
| 769 | 779 | 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 | ); | |
| 771 | 783 | 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 | ); | |
| 773 | 787 | }); |
| 774 | 788 | |
| 775 | 789 | 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* () { |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | 226 | 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))); | |
| 230 | 230 | |
| 231 | 231 | const startProviderSession = (input?: { |
| 232 | 232 | readonly resumeCursor?: unknown; |
| ... | ... | @@ -234,13 +234,15 @@ const make = Effect.gen(function* () { |
| 234 | 234 | }) => |
| 235 | 235 | providerService.startSession(threadId, { |
| 236 | 236 | threadId, |
| 237 | ...(input?.provider ?? preferredProvider | |
| 237 | ...((input?.provider ?? preferredProvider) | |
| 238 | 238 | ? { provider: input?.provider ?? preferredProvider } |
| 239 | 239 | : {}), |
| 240 | 240 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 241 | 241 | ...(desiredModel ? { model: desiredModel } : {}), |
| 242 | 242 | ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), |
| 243 | ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), | |
| 243 | ...(options?.providerOptions !== undefined | |
| 244 | ? { providerOptions: options.providerOptions } | |
| 245 | : {}), | |
| 244 | 246 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 245 | 247 | runtimeMode: desiredRuntimeMode, |
| 246 | 248 | }); |
| ... | ... | @@ -265,16 +267,15 @@ const make = Effect.gen(function* () { |
| 265 | 267 | thread.session && thread.session.status !== "stopped" ? thread.id : null; |
| 266 | 268 | if (existingSessionThreadId) { |
| 267 | 269 | 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; | |
| 269 | 272 | const activeSession = yield* resolveActiveSession(existingSessionThreadId); |
| 270 | 273 | const sessionModelSwitch = |
| 271 | 274 | currentProvider === undefined |
| 272 | 275 | ? "in-session" |
| 273 | 276 | : (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"; | |
| 278 | 279 | |
| 279 | 280 | if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { |
| 280 | 281 | return existingSessionThreadId; |
| ... | ... | @@ -345,15 +346,16 @@ const make = Effect.gen(function* () { |
| 345 | 346 | }); |
| 346 | 347 | const normalizedInput = toNonEmptyProviderInput(input.messageText); |
| 347 | 348 | 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 | ); | |
| 351 | 354 | const sessionModelSwitch = |
| 352 | 355 | activeSession === undefined |
| 353 | 356 | ? "in-session" |
| 354 | 357 | : (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; | |
| 357 | 359 | |
| 358 | 360 | yield* providerService.sendTurn({ |
| 359 | 361 | threadId: input.threadId, |
| ... | ... | @@ -474,8 +476,12 @@ const make = Effect.gen(function* () { |
| 474 | 476 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), |
| 475 | 477 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), |
| 476 | 478 | ...(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 | : {}), | |
| 479 | 485 | interactionMode: event.payload.interactionMode, |
| 480 | 486 | createdAt: event.payload.createdAt, |
| 481 | 487 | }); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-3| ... | ... | @@ -379,7 +379,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 379 | 379 | |
| 380 | 380 | await Effect.runPromise(Effect.sleep("40 millis")); |
| 381 | 381 | 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 | ); | |
| 383 | 385 | expect(midThread?.session?.status).toBe("running"); |
| 384 | 386 | expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle"); |
| 385 | 387 | |
| ... | ... | @@ -620,7 +622,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 620 | 622 | const proposedPlan = thread.proposedPlans.find( |
| 621 | 623 | (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final", |
| 622 | 624 | ); |
| 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 | ); | |
| 624 | 628 | }); |
| 625 | 629 | |
| 626 | 630 | it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => { |
| ... | ... | @@ -683,7 +687,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 683 | 687 | ), |
| 684 | 688 | ); |
| 685 | 689 | 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", | |
| 687 | 692 | ); |
| 688 | 693 | expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); |
| 689 | 694 | }); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+25-26| ... | ... | @@ -210,9 +210,9 @@ function runtimeEventToActivities( |
| 210 | 210 | ? "Command approval requested" |
| 211 | 211 | : requestKind === "file-read" |
| 212 | 212 | ? "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", | |
| 216 | 216 | payload: { |
| 217 | 217 | requestId: toApprovalRequestId(event.requestId), |
| 218 | 218 | ...(requestKind ? { requestKind } : {}), |
| ... | ... | @@ -298,7 +298,9 @@ function runtimeEventToActivities( |
| 298 | 298 | summary: "Plan updated", |
| 299 | 299 | payload: { |
| 300 | 300 | plan: event.payload.plan, |
| 301 | ...(event.payload.explanation !== undefined ? { explanation: event.payload.explanation } : {}), | |
| 301 | ...(event.payload.explanation !== undefined | |
| 302 | ? { explanation: event.payload.explanation } | |
| 303 | : {}), | |
| 302 | 304 | }, |
| 303 | 305 | turnId: toTurnId(event.turnId) ?? null, |
| 304 | 306 | ...maybeSequence, |
| ... | ... | @@ -358,7 +360,9 @@ function runtimeEventToActivities( |
| 358 | 360 | payload: { |
| 359 | 361 | taskId: event.payload.taskId, |
| 360 | 362 | ...(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 | : {}), | |
| 362 | 366 | }, |
| 363 | 367 | turnId: toTurnId(event.turnId) ?? null, |
| 364 | 368 | ...maybeSequence, |
| ... | ... | @@ -525,11 +529,7 @@ const make = Effect.gen(function* () { |
| 525 | 529 | return isGitRepository(workspaceCwd); |
| 526 | 530 | }); |
| 527 | 531 | |
| 528 | const rememberAssistantMessageId = ( | |
| 529 | threadId: ThreadId, | |
| 530 | turnId: TurnId, | |
| 531 | messageId: MessageId, | |
| 532 | ) => | |
| 532 | const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => | |
| 533 | 533 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( |
| 534 | 534 | Effect.flatMap((existingIds) => |
| 535 | 535 | Cache.set( |
| ... | ... | @@ -547,11 +547,7 @@ const make = Effect.gen(function* () { |
| 547 | 547 | ), |
| 548 | 548 | ); |
| 549 | 549 | |
| 550 | const forgetAssistantMessageId = ( | |
| 551 | threadId: ThreadId, | |
| 552 | turnId: TurnId, | |
| 553 | messageId: MessageId, | |
| 554 | ) => | |
| 550 | const forgetAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => | |
| 555 | 551 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( |
| 556 | 552 | Effect.flatMap((existingIds) => |
| 557 | 553 | Option.match(existingIds, { |
| ... | ... | @@ -616,7 +612,8 @@ const make = Effect.gen(function* () { |
| 616 | 612 | const existing = Option.getOrUndefined(existingEntry); |
| 617 | 613 | return Cache.set(bufferedProposedPlanById, planId, { |
| 618 | 614 | 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, | |
| 620 | 617 | }); |
| 621 | 618 | }), |
| 622 | 619 | ); |
| ... | ... | @@ -633,7 +630,8 @@ const make = Effect.gen(function* () { |
| 633 | 630 | const clearBufferedProposedPlan = (planId: string) => |
| 634 | 631 | Cache.invalidate(bufferedProposedPlanById, planId); |
| 635 | 632 | |
| 636 | const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); | |
| 633 | const clearAssistantMessageState = (messageId: MessageId) => | |
| 634 | clearBufferedAssistantText(messageId); | |
| 637 | 635 | |
| 638 | 636 | const finalizeAssistantMessage = (input: { |
| 639 | 637 | event: ProviderRuntimeEvent; |
| ... | ... | @@ -862,8 +860,8 @@ const make = Effect.gen(function* () { |
| 862 | 860 | : event.type === "turn.completed" && runtimeTurnState(event) === "failed" |
| 863 | 861 | ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") |
| 864 | 862 | : status === "ready" |
| 865 | ? null | |
| 866 | : (thread.session?.lastError ?? null); | |
| 863 | ? null | |
| 864 | : (thread.session?.lastError ?? null); | |
| 867 | 865 | |
| 868 | 866 | if (shouldApplyThreadLifecycle) { |
| 869 | 867 | yield* orchestrationEngine.dispatch({ |
| ... | ... | @@ -935,7 +933,9 @@ const make = Effect.gen(function* () { |
| 935 | 933 | const assistantCompletion = |
| 936 | 934 | event.type === "item.completed" && event.payload.itemType === "assistant_message" |
| 937 | 935 | ? { |
| 938 | messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), | |
| 936 | messageId: MessageId.makeUnsafe( | |
| 937 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, | |
| 938 | ), | |
| 939 | 939 | fallbackText: event.payload.detail, |
| 940 | 940 | } |
| 941 | 941 | : undefined; |
| ... | ... | @@ -951,10 +951,11 @@ const make = Effect.gen(function* () { |
| 951 | 951 | if (assistantCompletion) { |
| 952 | 952 | const assistantMessageId = assistantCompletion.messageId; |
| 953 | 953 | 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 | ); | |
| 955 | 957 | const shouldApplyFallbackCompletionText = |
| 956 | !existingAssistantMessage || | |
| 957 | existingAssistantMessage.text.length === 0; | |
| 958 | !existingAssistantMessage || existingAssistantMessage.text.length === 0; | |
| 958 | 959 | if (turnId) { |
| 959 | 960 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); |
| 960 | 961 | } |
| ... | ... | @@ -1029,9 +1030,7 @@ const make = Effect.gen(function* () { |
| 1029 | 1030 | |
| 1030 | 1031 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD |
| 1031 | 1032 | ? true |
| 1032 | : activeTurnId === null || | |
| 1033 | eventTurnId === undefined || | |
| 1034 | sameId(activeTurnId, eventTurnId); | |
| 1033 | : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); | |
| 1035 | 1034 | |
| 1036 | 1035 | if (shouldApplyRuntimeError) { |
| 1037 | 1036 | yield* orchestrationEngine.dispatch({ |
apps/server/src/orchestration/decider.ts+3-1| ... | ... | @@ -302,7 +302,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 302 | 302 | ...(command.provider !== undefined ? { provider: command.provider } : {}), |
| 303 | 303 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 304 | 304 | ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), |
| 305 | ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), | |
| 305 | ...(command.providerOptions !== undefined | |
| 306 | ? { providerOptions: command.providerOptions } | |
| 307 | : {}), | |
| 306 | 308 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, |
| 307 | 309 | runtimeMode: |
| 308 | 310 | readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? |
apps/server/src/orchestration/projector.ts+1-6| ... | ... | @@ -304,12 +304,7 @@ export function projectEvent( |
| 304 | 304 | ); |
| 305 | 305 | |
| 306 | 306 | 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( | |
| 313 | 308 | Effect.map((payload) => ({ |
| 314 | 309 | ...nextBase, |
| 315 | 310 | threads: updateThread(nextBase.threads, payload.threadId, { |
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+1-3| ... | ... | @@ -70,9 +70,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { |
| 70 | 70 | |
| 71 | 71 | const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => |
| 72 | 72 | upsertProjectionThreadProposedPlanRow(row).pipe( |
| 73 | Effect.mapError( | |
| 74 | toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"), | |
| 75 | ), | |
| 73 | Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), | |
| 76 | 74 | ); |
| 77 | 75 | |
| 78 | 76 | const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => |
apps/server/src/projectFaviconRoute.test.ts+9-5| ... | ... | @@ -20,9 +20,7 @@ function makeTempDir(prefix: string): string { |
| 20 | 20 | return dir; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | async function withRouteServer( | |
| 24 | run: (baseUrl: string) => Promise<void>, | |
| 25 | ): Promise<void> { | |
| 23 | async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> { | |
| 26 | 24 | const server = http.createServer((req, res) => { |
| 27 | 25 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); |
| 28 | 26 | if (tryHandleProjectFaviconRequest(url, res)) { |
| ... | ... | @@ -104,7 +102,10 @@ describe("tryHandleProjectFaviconRequest", () => { |
| 104 | 102 | const projectDir = makeTempDir("t3code-favicon-route-source-"); |
| 105 | 103 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); |
| 106 | 104 | 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 | ); | |
| 108 | 109 | fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8"); |
| 109 | 110 | |
| 110 | 111 | await withRouteServer(async (baseUrl) => { |
| ... | ... | @@ -120,7 +121,10 @@ describe("tryHandleProjectFaviconRequest", () => { |
| 120 | 121 | const projectDir = makeTempDir("t3code-favicon-route-html-order-"); |
| 121 | 122 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); |
| 122 | 123 | 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 | ); | |
| 124 | 128 | fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8"); |
| 125 | 129 | |
| 126 | 130 | await withRouteServer(async (baseUrl) => { |
apps/server/src/provider/Layers/CodexAdapter.ts+7-5| ... | ... | @@ -403,7 +403,9 @@ function asRuntimeTaskId(taskId: string): RuntimeTaskId { |
| 403 | 403 | return RuntimeTaskId.makeUnsafe(taskId); |
| 404 | 404 | } |
| 405 | 405 | |
| 406 | function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined { | |
| 406 | function codexEventMessage( | |
| 407 | payload: Record<string, unknown> | undefined, | |
| 408 | ): Record<string, unknown> | undefined { | |
| 407 | 409 | return asObject(payload?.msg); |
| 408 | 410 | } |
| 409 | 411 | |
| ... | ... | @@ -1037,7 +1039,9 @@ function mapToRuntimeEvents( |
| 1037 | 1039 | type: "content.delta", |
| 1038 | 1040 | payload: { |
| 1039 | 1041 | 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", | |
| 1041 | 1045 | delta, |
| 1042 | 1046 | ...(asNumber(msg?.summary_index) !== undefined |
| 1043 | 1047 | ? { summaryIndex: asNumber(msg?.summary_index) } |
| ... | ... | @@ -1314,9 +1318,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1314 | 1318 | detail: toMessage(cause, "Failed to start Codex adapter session."), |
| 1315 | 1319 | cause, |
| 1316 | 1320 | }), |
| 1317 | }).pipe( | |
| 1318 | Effect.map((session) => session), | |
| 1319 | ); | |
| 1321 | }).pipe(Effect.map((session) => session)); | |
| 1320 | 1322 | }; |
| 1321 | 1323 | |
| 1322 | 1324 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-4| ... | ... | @@ -29,10 +29,7 @@ const fakeCodexAdapter: CodexAdapterShape = { |
| 29 | 29 | |
| 30 | 30 | const layer = it.layer( |
| 31 | 31 | Layer.mergeAll( |
| 32 | Layer.provide( | |
| 33 | ProviderAdapterRegistryLive, | |
| 34 | Layer.succeed(CodexAdapter, fakeCodexAdapter), | |
| 35 | ), | |
| 32 | Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), | |
| 36 | 33 | NodeServices.layer, |
| 37 | 34 | ), |
| 38 | 35 | ); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-4| ... | ... | @@ -23,10 +23,7 @@ export interface ProviderAdapterRegistryLiveOptions { |
| 23 | 23 | |
| 24 | 24 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => |
| 25 | 25 | 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]; | |
| 30 | 27 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 31 | 28 | |
| 32 | 29 | 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", () = |
| 132 | 132 | ), |
| 133 | 133 | ); |
| 134 | 134 | |
| 135 | it.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 | ), | |
| 135 | it.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 | }), | |
| 158 | 154 | ), |
| 155 | ), | |
| 159 | 156 | ); |
| 160 | 157 | |
| 161 | 158 | it.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) => { |
| 635 | 635 | assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); |
| 636 | 636 | } |
| 637 | 637 | } |
| 638 | ||
| 639 | 638 | }), |
| 640 | 639 | ); |
| 641 | 640 | }); |
apps/server/src/provider/Layers/ProviderService.ts+22-20| ... | ... | @@ -141,9 +141,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 141 | 141 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 142 | 142 | Effect.succeed(event).pipe( |
| 143 | 143 | Effect.tap((canonicalEvent) => |
| 144 | canonicalEventLogger | |
| 145 | ? canonicalEventLogger.write(canonicalEvent, null) | |
| 146 | : Effect.void, | |
| 144 | canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, | |
| 147 | 145 | ), |
| 148 | 146 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), |
| 149 | 147 | Effect.asVoid, |
| ... | ... | @@ -193,7 +191,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 193 | 191 | const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); |
| 194 | 192 | if (hasActiveSession) { |
| 195 | 193 | 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 | ); | |
| 197 | 197 | if (existing) { |
| 198 | 198 | yield* upsertSessionBinding(existing, input.binding.threadId); |
| 199 | 199 | yield* analytics.record("provider.session.recovered", { |
| ... | ... | @@ -292,7 +292,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 292 | 292 | } |
| 293 | 293 | |
| 294 | 294 | yield* upsertSessionBinding(session, threadId, { |
| 295 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | |
| 295 | ...(input.providerOptions !== undefined | |
| 296 | ? { providerOptions: input.providerOptions } | |
| 297 | : {}), | |
| 296 | 298 | }); |
| 297 | 299 | yield* analytics.record("provider.session.started", { |
| 298 | 300 | provider: session.provider, |
| ... | ... | @@ -425,23 +427,23 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 425 | 427 | |
| 426 | 428 | const listSessions: ProviderServiceShape["listSessions"] = () => |
| 427 | 429 | 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 | ); | |
| 429 | 433 | 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" }, | |
| 442 | 443 | ), |
| 443 | Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>), | |
| 444 | ); | |
| 444 | ), | |
| 445 | Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>), | |
| 446 | ); | |
| 445 | 447 | const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>(); |
| 446 | 448 | for (const bindingOption of persistedBindings) { |
| 447 | 449 | 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"; |
| 2 | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 5 | import { | |
| 6 | ProviderSessionDirectoryPersistenceError, | |
| 7 | ProviderValidationError, | |
| 8 | } from "../Errors.ts"; | |
| 5 | import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; | |
| 9 | 6 | import { |
| 10 | 7 | ProviderSessionDirectory, |
| 11 | 8 | type ProviderRuntimeBinding, |
| ... | ... | @@ -138,7 +135,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 138 | 135 | const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => |
| 139 | 136 | repository |
| 140 | 137 | .deleteByThreadId({ threadId }) |
| 141 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId"))); | |
| 138 | .pipe( | |
| 139 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), | |
| 140 | ); | |
| 142 | 141 | |
| 143 | 142 | const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => |
| 144 | 143 | repository.list().pipe( |
apps/server/src/provider/Services/ProviderAdapter.ts+2-7| ... | ... | @@ -66,10 +66,7 @@ export interface ProviderAdapterShape<TError> { |
| 66 | 66 | /** |
| 67 | 67 | * Interrupt an active turn. |
| 68 | 68 | */ |
| 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>; | |
| 73 | 70 | |
| 74 | 71 | /** |
| 75 | 72 | * Respond to an interactive approval request. |
| ... | ... | @@ -107,9 +104,7 @@ export interface ProviderAdapterShape<TError> { |
| 107 | 104 | /** |
| 108 | 105 | * Read a provider thread snapshot. |
| 109 | 106 | */ |
| 110 | readonly readThread: ( | |
| 111 | threadId: ThreadId, | |
| 112 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; | |
| 107 | readonly readThread: (threadId: ThreadId) => Effect.Effect<ProviderThreadSnapshot, TError>; | |
| 113 | 108 | |
| 114 | 109 | /** |
| 115 | 110 | * Roll back a provider thread by N turns. |
apps/server/src/wsServer.test.ts+16-16| ... | ... | @@ -1236,21 +1236,19 @@ describe("WebSocket Server", () => { |
| 1236 | 1236 | return event.type === "thread.session-set"; |
| 1237 | 1237 | }); |
| 1238 | 1238 | |
| 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); | |
| 1254 | 1252 | |
| 1255 | 1253 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { |
| 1256 | 1254 | const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; |
| ... | ... | @@ -1568,7 +1566,9 @@ describe("WebSocket Server", () => { |
| 1568 | 1566 | }); |
| 1569 | 1567 | |
| 1570 | 1568 | 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 | ); | |
| 1572 | 1572 | expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); |
| 1573 | 1573 | }); |
| 1574 | 1574 |
apps/server/src/wsServer.ts+12-14| ... | ... | @@ -198,8 +198,7 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 198 | 198 | |
| 199 | 199 | function messageFromCause(cause: Cause.Cause<unknown>): string { |
| 200 | 200 | 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(); | |
| 203 | 202 | return message.length > 0 ? message : Cause.pretty(cause); |
| 204 | 203 | } |
| 205 | 204 | |
| ... | ... | @@ -330,10 +329,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 330 | 329 | } satisfies OrchestrationCommand; |
| 331 | 330 | } |
| 332 | 331 | |
| 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) { | |
| 337 | 333 | return { |
| 338 | 334 | ...input.command, |
| 339 | 335 | workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), |
| ... | ... | @@ -793,14 +789,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 793 | 789 | relativePath: body.relativePath, |
| 794 | 790 | path, |
| 795 | 791 | }); |
| 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 | ); | |
| 804 | 802 | yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( |
| 805 | 803 | Effect.mapError( |
| 806 | 804 | (cause) => |
apps/web/src/appSettings.test.ts+2-12| ... | ... | @@ -61,23 +61,13 @@ describe("resolveAppModelSelection", () => { |
| 61 | 61 | |
| 62 | 62 | describe("getSlashModelOptions", () => { |
| 63 | 63 | 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"); | |
| 70 | 65 | |
| 71 | 66 | expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); |
| 72 | 67 | }); |
| 73 | 68 | |
| 74 | 69 | 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"); | |
| 81 | 71 | |
| 82 | 72 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); |
| 83 | 73 | }); |
apps/web/src/components/BranchToolbar.tsx+1-3| ... | ... | @@ -130,9 +130,7 @@ export default function BranchToolbar({ |
| 130 | 130 | effectiveEnvMode={effectiveEnvMode} |
| 131 | 131 | envLocked={envLocked} |
| 132 | 132 | onSetThreadBranch={setThreadBranch} |
| 133 | {...(onCheckoutPullRequestRequest | |
| 134 | ? { onCheckoutPullRequestRequest } | |
| 135 | : {})} | |
| 133 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} | |
| 136 | 134 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 137 | 135 | /> |
| 138 | 136 | </div> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-19| ... | ... | @@ -113,27 +113,23 @@ export function BranchToolbarBranchSelector({ |
| 113 | 113 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| 114 | 114 | const isSelectingWorktreeBase = |
| 115 | 115 | 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; | |
| 119 | 118 | const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; |
| 120 | 119 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 121 | 120 | const createBranchItemValue = canCreateBranch |
| 122 | 121 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| 123 | 122 | : 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]); | |
| 137 | 133 | const filteredBranchPickerItems = useMemo( |
| 138 | 134 | () => |
| 139 | 135 | normalizedDeferredBranchQuery.length === 0 |
| ... | ... | @@ -397,9 +393,7 @@ export function BranchToolbarBranchSelector({ |
| 397 | 393 | > |
| 398 | 394 | <div className="flex w-full items-center justify-between gap-2"> |
| 399 | 395 | <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>} | |
| 403 | 397 | </div> |
| 404 | 398 | </ComboboxItem> |
| 405 | 399 | ); |
apps/web/src/components/ChatView.browser.tsx+18-8| ... | ... | @@ -414,9 +414,9 @@ async function setViewport(viewport: ViewportSpec): Promise<void> { |
| 414 | 414 | async function waitForProductionStyles(): Promise<void> { |
| 415 | 415 | await vi.waitFor( |
| 416 | 416 | () => { |
| 417 | expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe( | |
| 418 | "", | |
| 419 | ); | |
| 417 | expect( | |
| 418 | getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), | |
| 419 | ).not.toBe(""); | |
| 420 | 420 | expect(getComputedStyle(document.body).marginTop).toBe("0px"); |
| 421 | 421 | }, |
| 422 | 422 | { |
| ... | ... | @@ -454,7 +454,9 @@ async function waitForComposerEditor(): Promise<HTMLElement> { |
| 454 | 454 | ); |
| 455 | 455 | } |
| 456 | 456 | |
| 457 | async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise<HTMLButtonElement> { | |
| 457 | async function waitForInteractionModeButton( | |
| 458 | expectedLabel: "Chat" | "Plan", | |
| 459 | ): Promise<HTMLButtonElement> { | |
| 458 | 460 | return waitForElement( |
| 459 | 461 | () => |
| 460 | 462 | Array.from(document.querySelectorAll("button")).find( |
| ... | ... | @@ -697,7 +699,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 697 | 699 | }); |
| 698 | 700 | |
| 699 | 701 | try { |
| 700 | const measurements: Array<UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }> = []; | |
| 702 | const measurements: Array< | |
| 703 | UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } | |
| 704 | > = []; | |
| 701 | 705 | |
| 702 | 706 | for (const viewport of TEXT_VIEWPORT_MATRIX) { |
| 703 | 707 | await mounted.setViewport(viewport); |
| ... | ... | @@ -714,7 +718,10 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 714 | 718 | measurements.push({ ...measurement, viewport, estimatedHeightPx }); |
| 715 | 719 | } |
| 716 | 720 | |
| 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); | |
| 718 | 725 | |
| 719 | 726 | const byMeasuredWidth = measurements.toSorted( |
| 720 | 727 | (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, |
| ... | ... | @@ -756,7 +763,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 756 | 763 | { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, |
| 757 | 764 | ); |
| 758 | 765 | |
| 759 | const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; | |
| 766 | const measuredDeltaPx = | |
| 767 | mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; | |
| 760 | 768 | const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; |
| 761 | 769 | expect(measuredDeltaPx).toBeGreaterThan(0); |
| 762 | 770 | expect(estimatedDeltaPx).toBeGreaterThan(0); |
| ... | ... | @@ -844,7 +852,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 844 | 852 | |
| 845 | 853 | await vi.waitFor( |
| 846 | 854 | () => { |
| 847 | const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor); | |
| 855 | const openRequest = wsRequests.find( | |
| 856 | (request) => request._tag === WS_METHODS.shellOpenInEditor, | |
| 857 | ); | |
| 848 | 858 | expect(openRequest).toMatchObject({ |
| 849 | 859 | _tag: WS_METHODS.shellOpenInEditor, |
| 850 | 860 | cwd: "/repo/project", |
apps/web/src/components/ChatView.tsx+21-19| ... | ... | @@ -615,7 +615,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 615 | 615 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 616 | 616 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 617 | 617 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 618 | const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId); | |
| 618 | const getDraftThreadByProjectId = useComposerDraftStore( | |
| 619 | (store) => store.getDraftThreadByProjectId, | |
| 620 | ); | |
| 619 | 621 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); |
| 620 | 622 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); |
| 621 | 623 | const clearProjectDraftThreadId = useComposerDraftStore( |
| ... | ... | @@ -657,9 +659,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 657 | 659 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 658 | 660 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 659 | 661 | 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); | |
| 663 | 664 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 664 | 665 | Record<string, string[]> |
| 665 | 666 | >({}); |
| ... | ... | @@ -765,16 +766,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 765 | 766 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 766 | 767 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); |
| 767 | 768 | |
| 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 | ); | |
| 778 | 782 | |
| 779 | 783 | const closePullRequestDialog = useCallback(() => { |
| 780 | 784 | setPullRequestDialogState(null); |
| ... | ... | @@ -4131,7 +4135,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4131 | 4135 | onPrepared={handlePreparedPullRequestThread} |
| 4132 | 4136 | /> |
| 4133 | 4137 | ) : null} |
| 4134 | </div>{/* end chat column */} | |
| 4138 | </div> | |
| 4139 | {/* end chat column */} | |
| 4135 | 4140 | |
| 4136 | 4141 | {/* Plan sidebar */} |
| 4137 | 4142 | {planSidebarOpen ? ( |
| ... | ... | @@ -5744,10 +5749,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 5744 | 5749 | } |
| 5745 | 5750 | > |
| 5746 | 5751 | <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)} | |
| 5751 | 5753 | > |
| 5752 | 5754 | <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> |
| 5753 | 5755 | <span className="truncate">{selectedModelLabel}</span> |
apps/web/src/components/ComposerPromptEditor.tsx+48-48| ... | ... | @@ -1,7 +1,4 @@ |
| 1 | import { | |
| 2 | LexicalComposer, | |
| 3 | type InitialConfigType, | |
| 4 | } from "@lexical/react/LexicalComposer"; | |
| 1 | import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; | |
| 5 | 2 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; |
| 6 | 3 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; |
| 7 | 4 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; |
| ... | ... | @@ -331,12 +328,11 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { |
| 331 | 328 | const composerLength = $getComposerRootLength(); |
| 332 | 329 | const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); |
| 333 | 330 | 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 | }; | |
| 340 | 336 | const selection = $createRangeSelection(); |
| 341 | 337 | selection.anchor.set(point.key, point.offset, point.type); |
| 342 | 338 | selection.focus.set(point.key, point.offset, point.type); |
| ... | ... | @@ -724,7 +720,10 @@ function ComposerPromptEditorInner({ |
| 724 | 720 | editorState.read(() => { |
| 725 | 721 | const nextValue = $getRoot().getTextContent(); |
| 726 | 722 | const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); |
| 727 | const nextCursor = clampCursor(nextValue, $readSelectionOffsetFromEditorState(fallbackCursor)); | |
| 723 | const nextCursor = clampCursor( | |
| 724 | nextValue, | |
| 725 | $readSelectionOffsetFromEditorState(fallbackCursor), | |
| 726 | ); | |
| 728 | 727 | const previousSnapshot = snapshotRef.current; |
| 729 | 728 | if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { |
| 730 | 729 | return; |
| ... | ... | @@ -771,41 +770,42 @@ function ComposerPromptEditorInner({ |
| 771 | 770 | ); |
| 772 | 771 | } |
| 773 | 772 | |
| 774 | export 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 | ); | |
| 773 | export 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 | ); | |
| 794 | 795 | |
| 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"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 6 | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 7 | import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | import { | |
| 8 | type WheelEvent as ReactWheelEvent, | |
| 9 | useCallback, | |
| 10 | useEffect, | |
| 11 | useMemo, | |
| 12 | useRef, | |
| 13 | useState, | |
| 14 | } from "react"; | |
| 8 | 15 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; |
| 9 | 16 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 10 | 17 | import { 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", () => { |
| 577 | 577 | }); |
| 578 | 578 | |
| 579 | 579 | 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); | |
| 584 | 581 | assert.deepEqual(items, [ |
| 585 | 582 | { |
| 586 | 583 | id: "commit", |
| ... | ... | @@ -627,10 +624,7 @@ describe("when: branch has no upstream configured", () => { |
| 627 | 624 | }); |
| 628 | 625 | |
| 629 | 626 | 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); | |
| 634 | 628 | assert.deepEqual(items, [ |
| 635 | 629 | { |
| 636 | 630 | id: "commit", |
apps/web/src/components/GitActionsControl.logic.ts+1-6| ... | ... | @@ -123,12 +123,7 @@ export function buildMenuItems( |
| 123 | 123 | const canCommit = !isBusy && hasChanges; |
| 124 | 124 | const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; |
| 125 | 125 | 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; | |
| 132 | 127 | const canOpenPr = !isBusy && hasOpenPr; |
| 133 | 128 | |
| 134 | 129 | return [ |
apps/web/src/components/GitActionsControl.tsx+17-11| ... | ... | @@ -255,7 +255,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 255 | 255 | }) => { |
| 256 | 256 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 257 | 257 | const actionBranch = actionStatus?.branch ?? null; |
| 258 | const actionIsDefaultBranch = isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); | |
| 258 | const actionIsDefaultBranch = | |
| 259 | isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); | |
| 259 | 260 | const includesCommit = |
| 260 | 261 | !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); |
| 261 | 262 | if ( |
| ... | ... | @@ -331,7 +332,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 331 | 332 | stopProgressUpdates(); |
| 332 | 333 | const resultToast = summarizeGitResult(result); |
| 333 | 334 | |
| 334 | const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; | |
| 335 | const existingOpenPrUrl = | |
| 336 | actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; | |
| 335 | 337 | const prUrl = result.pr.url ?? existingOpenPrUrl; |
| 336 | 338 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 337 | 339 | const shouldOfferOpenPrCta = |
| ... | ... | @@ -374,12 +376,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 374 | 376 | }, |
| 375 | 377 | } |
| 376 | 378 | : 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; | |
| 383 | 385 | closeResultToast(); |
| 384 | 386 | void api.shell.openExternal(prUrl); |
| 385 | 387 | }, |
| ... | ... | @@ -424,7 +426,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 424 | 426 | |
| 425 | 427 | const continuePendingDefaultBranchAction = useCallback(() => { |
| 426 | 428 | if (!pendingDefaultBranchAction) return; |
| 427 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; | |
| 429 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = | |
| 430 | pendingDefaultBranchAction; | |
| 428 | 431 | setPendingDefaultBranchAction(null); |
| 429 | 432 | void runGitActionWithToast({ |
| 430 | 433 | action, |
| ... | ... | @@ -453,7 +456,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 453 | 456 | |
| 454 | 457 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 455 | 458 | if (!pendingDefaultBranchAction) return; |
| 456 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; | |
| 459 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = | |
| 460 | pendingDefaultBranchAction; | |
| 457 | 461 | setPendingDefaultBranchAction(null); |
| 458 | 462 | checkoutNewBranchAndRunAction({ |
| 459 | 463 | action, |
| ... | ... | @@ -725,7 +729,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 725 | 729 | <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1"> |
| 726 | 730 | <span className="text-muted-foreground">Branch</span> |
| 727 | 731 | <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> | |
| 729 | 735 | {isDefaultBranch && ( |
| 730 | 736 | <span className="text-right text-warning text-xs">Warning: default branch</span> |
| 731 | 737 | )} |
apps/web/src/components/Icons.tsx+13-52| ... | ... | @@ -74,10 +74,7 @@ export const VisualStudioCode: Icon = (props) => { |
| 74 | 74 | filterUnits="userSpaceOnUse" |
| 75 | 75 | > |
| 76 | 76 | <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" /> | |
| 81 | 78 | <feOffset /> |
| 82 | 79 | <feGaussianBlur stdDeviation="4.167" /> |
| 83 | 80 | <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) => { |
| 94 | 91 | filterUnits="userSpaceOnUse" |
| 95 | 92 | > |
| 96 | 93 | <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" /> | |
| 101 | 95 | <feOffset /> |
| 102 | 96 | <feGaussianBlur stdDeviation="4.167" /> |
| 103 | 97 | <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) => ( |
| 185 | 179 | <ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" /> |
| 186 | 180 | </g> |
| 187 | 181 | <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" /> | |
| 192 | 183 | </g> |
| 193 | 184 | <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" /> | |
| 198 | 186 | </g> |
| 199 | 187 | <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" /> | |
| 204 | 189 | </g> |
| 205 | 190 | <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" /> | |
| 210 | 192 | </g> |
| 211 | 193 | </g> |
| 212 | 194 | <defs> |
| ... | ... | @@ -221,10 +203,7 @@ export const Gemini: Icon = (props) => ( |
| 221 | 203 | > |
| 222 | 204 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 223 | 205 | <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" /> | |
| 228 | 207 | </filter> |
| 229 | 208 | <filter |
| 230 | 209 | id="gemini__c" |
| ... | ... | @@ -237,10 +216,7 @@ export const Gemini: Icon = (props) => ( |
| 237 | 216 | > |
| 238 | 217 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 239 | 218 | <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" /> | |
| 244 | 220 | </filter> |
| 245 | 221 | <filter |
| 246 | 222 | id="gemini__d" |
| ... | ... | @@ -253,10 +229,7 @@ export const Gemini: Icon = (props) => ( |
| 253 | 229 | > |
| 254 | 230 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 255 | 231 | <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" /> | |
| 260 | 233 | </filter> |
| 261 | 234 | <filter |
| 262 | 235 | id="gemini__e" |
| ... | ... | @@ -269,10 +242,7 @@ export const Gemini: Icon = (props) => ( |
| 269 | 242 | > |
| 270 | 243 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 271 | 244 | <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" /> | |
| 276 | 246 | </filter> |
| 277 | 247 | <filter |
| 278 | 248 | id="gemini__f" |
| ... | ... | @@ -285,10 +255,7 @@ export const Gemini: Icon = (props) => ( |
| 285 | 255 | > |
| 286 | 256 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 287 | 257 | <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" /> | |
| 292 | 259 | </filter> |
| 293 | 260 | <filter |
| 294 | 261 | id="gemini__g" |
| ... | ... | @@ -301,10 +268,7 @@ export const Gemini: Icon = (props) => ( |
| 301 | 268 | > |
| 302 | 269 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 303 | 270 | <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" /> | |
| 308 | 272 | </filter> |
| 309 | 273 | <filter |
| 310 | 274 | id="gemini__h" |
| ... | ... | @@ -317,10 +281,7 @@ export const Gemini: Icon = (props) => ( |
| 317 | 281 | > |
| 318 | 282 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> |
| 319 | 283 | <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" /> | |
| 324 | 285 | </filter> |
| 325 | 286 | </defs> |
| 326 | 287 | </svg> |
apps/web/src/components/PlanSidebar.tsx+3-8| ... | ... | @@ -106,8 +106,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 106 | 106 | toastManager.add({ |
| 107 | 107 | type: "error", |
| 108 | 108 | 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.", | |
| 111 | 110 | }); |
| 112 | 111 | }) |
| 113 | 112 | .then( |
| ... | ... | @@ -199,9 +198,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 199 | 198 | step.status === "completed" && "bg-emerald-500/5", |
| 200 | 199 | )} |
| 201 | 200 | > |
| 202 | <div className="mt-0.5"> | |
| 203 | {stepStatusIcon(step.status)} | |
| 204 | </div> | |
| 201 | <div className="mt-0.5">{stepStatusIcon(step.status)}</div> | |
| 205 | 202 | <p |
| 206 | 203 | className={cn( |
| 207 | 204 | "text-[13px] leading-snug", |
| ... | ... | @@ -251,9 +248,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 251 | 248 | {/* Empty state */} |
| 252 | 249 | {!activePlan && !planMarkdown ? ( |
| 253 | 250 | <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> | |
| 257 | 252 | <p className="mt-1 text-[11px] text-muted-foreground/30"> |
| 258 | 253 | Plans will appear here when generated. |
| 259 | 254 | </p> |
apps/web/src/components/PullRequestThreadDialog.tsx+48-46| ... | ... | @@ -116,56 +116,58 @@ export function PullRequestThreadDialog({ |
| 116 | 116 | } |
| 117 | 117 | }, [resolvedPullRequest?.state]); |
| 118 | 118 | |
| 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 | ); | |
| 149 | 152 | |
| 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; | |
| 158 | 160 | const errorMessage = |
| 159 | 161 | validationMessage ?? |
| 160 | 162 | (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); | |
| 169 | 171 | |
| 170 | 172 | return ( |
| 171 | 173 | <Dialog |
apps/web/src/components/Sidebar.logic.ts+1-2| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 | import type { Thread } from "../types"; |
| 2 | 2 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; |
| 3 | 3 | |
| 4 | export const THREAD_SELECTION_SAFE_SELECTOR = | |
| 5 | "[data-thread-item], [data-thread-selection-safe]"; | |
| 4 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | |
| 6 | 5 | |
| 7 | 6 | export interface ThreadStatusPill { |
| 8 | 7 | label: |
apps/web/src/components/Sidebar.tsx+226-186| ... | ... | @@ -83,10 +83,7 @@ import { |
| 83 | 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 84 | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | import { | |
| 87 | resolveThreadStatusPill, | |
| 88 | shouldClearThreadSelectionOnMouseDown, | |
| 89 | } from "./Sidebar.logic"; | |
| 86 | import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; | |
| 90 | 87 | |
| 91 | 88 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 92 | 89 | const THREAD_PREVIEW_LIMIT = 6; |
| ... | ... | @@ -897,7 +894,14 @@ export default function Sidebar() { |
| 897 | 894 | params: { threadId }, |
| 898 | 895 | }); |
| 899 | 896 | }, |
| 900 | [clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection], | |
| 897 | [ | |
| 898 | clearSelection, | |
| 899 | navigate, | |
| 900 | rangeSelectTo, | |
| 901 | selectedThreadIds.size, | |
| 902 | setSelectionAnchor, | |
| 903 | toggleThreadSelection, | |
| 904 | ], | |
| 901 | 905 | ); |
| 902 | 906 | |
| 903 | 907 | const handleProjectContextMenu = useCallback( |
| ... | ... | @@ -1077,7 +1081,16 @@ export default function Sidebar() { |
| 1077 | 1081 | window.removeEventListener("keydown", onWindowKeyDown); |
| 1078 | 1082 | window.removeEventListener("mousedown", onMouseDown); |
| 1079 | 1083 | }; |
| 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 | ]); | |
| 1081 | 1094 | |
| 1082 | 1095 | useEffect(() => { |
| 1083 | 1096 | if (!isElectron) return; |
| ... | ... | @@ -1403,7 +1416,8 @@ export default function Sidebar() { |
| 1403 | 1416 | const projectThreads = threads |
| 1404 | 1417 | .filter((thread) => thread.projectId === project.id) |
| 1405 | 1418 | .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(); | |
| 1407 | 1421 | if (byDate !== 0) return byDate; |
| 1408 | 1422 | return b.id.localeCompare(a.id); |
| 1409 | 1423 | }); |
| ... | ... | @@ -1418,10 +1432,7 @@ export default function Sidebar() { |
| 1418 | 1432 | return ( |
| 1419 | 1433 | <SortableProjectItem key={project.id} projectId={project.id}> |
| 1420 | 1434 | {(dragHandleProps) => ( |
| 1421 | <Collapsible | |
| 1422 | className="group/collapsible" | |
| 1423 | open={project.expanded} | |
| 1424 | > | |
| 1435 | <Collapsible className="group/collapsible" open={project.expanded}> | |
| 1425 | 1436 | <div className="group/project-header relative"> |
| 1426 | 1437 | <SidebarMenuButton |
| 1427 | 1438 | size="sm" |
| ... | ... | @@ -1481,193 +1492,222 @@ export default function Sidebar() { |
| 1481 | 1492 | |
| 1482 | 1493 | <CollapsibleContent keepMounted> |
| 1483 | 1494 | <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 { | |
| 1518 | 1561 | if (selectedThreadIds.size > 0) { |
| 1519 | 1562 | clearSelection(); |
| 1520 | 1563 | } |
| 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, | |
| 1525 | 1567 | }); |
| 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 | } | |
| 1607 | 1587 | /> |
| 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 | > | |
| 1616 | 1597 | <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} | |
| 1625 | 1604 | </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 && ( | |
| 1627 | 1654 | <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}`} | |
| 1631 | 1659 | > |
| 1632 | {formatRelativeTime(thread.createdAt)} | |
| 1660 | <TerminalIcon | |
| 1661 | className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} | |
| 1662 | /> | |
| 1633 | 1663 | </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> | |
| 1652 | 1675 | </SidebarMenuSubButton> |
| 1653 | 1676 | </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> | |
| 1671 | 1711 | </CollapsibleContent> |
| 1672 | 1712 | </Collapsible> |
| 1673 | 1713 | )} |
apps/web/src/components/composerFooterLayout.test.ts+1-3| ... | ... | @@ -17,9 +17,7 @@ describe("shouldUseCompactComposerFooter", () => { |
| 17 | 17 | |
| 18 | 18 | it("stays expanded at and above the breakpoint", () => { |
| 19 | 19 | 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); | |
| 23 | 21 | }); |
| 24 | 22 | |
| 25 | 23 | 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 = { |
| 31 | 31 | |
| 32 | 32 | describe("desktop update button state", () => { |
| 33 | 33 | 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 | }; | |
| 35 | 39 | expect(shouldShowDesktopUpdateButton(state)).toBe(true); |
| 36 | 40 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); |
| 37 | 41 | }); |
apps/web/src/components/ui/scroll-area.tsx+2-1| ... | ... | @@ -24,7 +24,8 @@ function ScrollArea({ |
| 24 | 24 | scrollFade && |
| 25 | 25 | "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]", |
| 26 | 26 | 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", | |
| 28 | 29 | )} |
| 29 | 30 | data-slot="scroll-area-viewport" |
| 30 | 31 | > |
apps/web/src/components/ui/toast.logic.test.ts+10-2| ... | ... | @@ -17,7 +17,11 @@ describe("shouldHideCollapsedToastContent", () => { |
| 17 | 17 | |
| 18 | 18 | describe("buildVisibleToastLayout", () => { |
| 19 | 19 | 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 | ]; | |
| 21 | 25 | |
| 22 | 26 | const layout = buildVisibleToastLayout(visibleToasts); |
| 23 | 27 | |
| ... | ... | @@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => { |
| 37 | 41 | }); |
| 38 | 42 | |
| 39 | 43 | 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 | ]); | |
| 41 | 49 | |
| 42 | 50 | assert.equal(layout.frontmostHeight, 0); |
| 43 | 51 | assert.deepEqual( |
apps/web/src/composer-logic.ts+7-10| ... | ... | @@ -10,11 +10,7 @@ export interface ComposerTrigger { |
| 10 | 10 | rangeEnd: number; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ | |
| 14 | "model", | |
| 15 | "plan", | |
| 16 | "default", | |
| 17 | ]; | |
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; | |
| 18 | 14 | |
| 19 | 15 | function clampCursor(text: string, cursor: number): number { |
| 20 | 16 | if (!Number.isFinite(cursor)) return text.length; |
| ... | ... | @@ -65,7 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 65 | 61 | return expandedCursor; |
| 66 | 62 | } |
| 67 | 63 | |
| 68 | function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { | |
| 64 | function collapsedSegmentLength( | |
| 65 | segment: { type: "text"; text: string } | { type: "mention" }, | |
| 66 | ): number { | |
| 69 | 67 | return segment.type === "mention" ? 1 : segment.text.length; |
| 70 | 68 | } |
| 71 | 69 | |
| ... | ... | @@ -164,10 +162,9 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 164 | 162 | }; |
| 165 | 163 | } |
| 166 | 164 | |
| 167 | export function parseStandaloneComposerSlashCommand(text: string): Exclude< | |
| 168 | ComposerSlashCommand, | |
| 169 | "model" | |
| 170 | > | null { | |
| 165 | export function parseStandaloneComposerSlashCommand( | |
| 166 | text: string, | |
| 167 | ): Exclude<ComposerSlashCommand, "model"> | null { | |
| 171 | 168 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); |
| 172 | 169 | if (!match) { |
| 173 | 170 | return null; |
apps/web/src/composerDraftStore.test.ts+6-4| ... | ... | @@ -252,9 +252,9 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 252 | 252 | store.clearProjectDraftThreadId(projectId); |
| 253 | 253 | |
| 254 | 254 | 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); | |
| 258 | 258 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); |
| 259 | 259 | }); |
| 260 | 260 | |
| ... | ... | @@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => { |
| 380 | 380 | |
| 381 | 381 | store.setModel(threadId, "gpt-5.3-codex"); |
| 382 | 382 | |
| 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 | ); | |
| 384 | 386 | }); |
| 385 | 387 | }); |
| 386 | 388 |
apps/web/src/composerDraftStore.ts+8-11| ... | ... | @@ -9,11 +9,7 @@ import { |
| 9 | 9 | type RuntimeMode, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 12 | import { | |
| 13 | DEFAULT_INTERACTION_MODE, | |
| 14 | DEFAULT_RUNTIME_MODE, | |
| 15 | type ChatImageAttachment, | |
| 16 | } from "./types"; | |
| 12 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | |
| 17 | 13 | import { Debouncer } from "@tanstack/react-pacer"; |
| 18 | 14 | import { create } from "zustand"; |
| 19 | 15 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; |
| ... | ... | @@ -495,8 +491,7 @@ function hydreatePersistedComposerImageAttachment( |
| 495 | 491 | attachment: PersistedComposerImageAttachment, |
| 496 | 492 | ): File | null { |
| 497 | 493 | 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); | |
| 500 | 495 | const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); |
| 501 | 496 | if (payload.length === 0) { |
| 502 | 497 | return null; |
| ... | ... | @@ -605,7 +600,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 605 | 600 | const nextDraftThread: DraftThreadState = { |
| 606 | 601 | projectId, |
| 607 | 602 | 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, | |
| 609 | 605 | interactionMode: |
| 610 | 606 | options?.interactionMode ?? |
| 611 | 607 | existingThread?.interactionMode ?? |
| ... | ... | @@ -673,7 +669,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 673 | 669 | return state; |
| 674 | 670 | } |
| 675 | 671 | const nextWorktreePath = |
| 676 | options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null); | |
| 672 | options.worktreePath === undefined | |
| 673 | ? existing.worktreePath | |
| 674 | : (options.worktreePath ?? null); | |
| 677 | 675 | const nextDraftThread: DraftThreadState = { |
| 678 | 676 | projectId: nextProjectId, |
| 679 | 677 | createdAt: |
| ... | ... | @@ -685,8 +683,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 685 | 683 | branch: options.branch === undefined ? existing.branch : (options.branch ?? null), |
| 686 | 684 | worktreePath: nextWorktreePath, |
| 687 | 685 | envMode: |
| 688 | options.envMode ?? | |
| 689 | (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), | |
| 686 | options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), | |
| 690 | 687 | }; |
| 691 | 688 | const isUnchanged = |
| 692 | 689 | nextDraftThread.projectId === existing.projectId && |
apps/web/src/lib/gitReactQuery.ts+1-7| ... | ... | @@ -196,13 +196,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { |
| 196 | 196 | queryClient: QueryClient; |
| 197 | 197 | }) { |
| 198 | 198 | 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" }) => { | |
| 206 | 200 | const api = ensureNativeApi(); |
| 207 | 201 | if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); |
| 208 | 202 | return api.git.preparePullRequestThread({ |
apps/web/src/lib/turnDiffTree.test.ts+5-2| ... | ... | @@ -91,7 +91,9 @@ describe("buildTurnDiffTree", () => { |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | 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 | ]); | |
| 95 | 97 | |
| 96 | 98 | expect(tree).toEqual([ |
| 97 | 99 | { |
| ... | ... | @@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => { |
| 157 | 159 | |
| 158 | 160 | expect(tree).toHaveLength(2); |
| 159 | 161 | 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", | |
| 161 | 164 | ); |
| 162 | 165 | expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]); |
| 163 | 166 | expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); |
apps/web/src/pendingUserInput.ts+1-3| ... | ... | @@ -95,9 +95,7 @@ export function derivePendingUserInputProgress( |
| 95 | 95 | questionIndex: number, |
| 96 | 96 | ): PendingUserInputProgress { |
| 97 | 97 | 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)); | |
| 101 | 99 | const activeQuestion = questions[normalizedQuestionIndex] ?? null; |
| 102 | 100 | const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; |
| 103 | 101 | const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); |
apps/web/src/proposedPlan.test.ts+6-3| ... | ... | @@ -31,9 +31,12 @@ describe("buildPlanImplementationPrompt", () => { |
| 31 | 31 | describe("buildCollapsedProposedPlanPreviewMarkdown", () => { |
| 32 | 32 | it("drops the redundant title heading and preserves the following markdown lines", () => { |
| 33 | 33 | 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 | ), | |
| 37 | 40 | ).toBe("- step 1\n- step 2"); |
| 38 | 41 | }); |
| 39 | 42 |
apps/web/src/proposedPlan.ts+2-6| ... | ... | @@ -5,8 +5,7 @@ export function proposedPlanTitle(planMarkdown: string): string | null { |
| 5 | 5 | |
| 6 | 6 | export function stripDisplayedPlanMarkdown(planMarkdown: string): string { |
| 7 | 7 | 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]; | |
| 10 | 9 | while (sourceLines[0]?.trim().length === 0) { |
| 11 | 10 | sourceLines.shift(); |
| 12 | 11 | } |
| ... | ... | @@ -75,10 +74,7 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string { |
| 75 | 74 | return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; |
| 76 | 75 | } |
| 77 | 76 | |
| 78 | export function resolvePlanFollowUpSubmission(input: { | |
| 79 | draftText: string; | |
| 80 | planMarkdown: string; | |
| 81 | }): { | |
| 77 | export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { | |
| 82 | 78 | text: string; |
| 83 | 79 | interactionMode: "default" | "plan"; |
| 84 | 80 | } { |
apps/web/src/pullRequestReference.test.ts+3-3| ... | ... | @@ -4,9 +4,9 @@ import { parsePullRequestReference } from "./pullRequestReference"; |
| 4 | 4 | |
| 5 | 5 | describe("parsePullRequestReference", () => { |
| 6 | 6 | 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 | ); | |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | 12 | it("accepts raw numbers", () => { |
apps/web/src/routes/_chat.$threadId.tsx+6-6| ... | ... | @@ -92,7 +92,9 @@ const DiffPanelInlineSidebar = (props: { |
| 92 | 92 | composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, |
| 93 | 93 | ); |
| 94 | 94 | 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 | ); | |
| 96 | 98 | const composerRightActions = composerForm.querySelector<HTMLElement>( |
| 97 | 99 | "[data-chat-composer-actions='right']", |
| 98 | 100 | ); |
| ... | ... | @@ -103,9 +105,7 @@ const DiffPanelInlineSidebar = (props: { |
| 103 | 105 | 0 |
| 104 | 106 | : 0; |
| 105 | 107 | const minimumComposerWidth = |
| 106 | COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + | |
| 107 | composerRightActionsWidth + | |
| 108 | composerFooterGap; | |
| 108 | COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; | |
| 109 | 109 | const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; |
| 110 | 110 | const overflowsViewport = formRect.width > viewportContentWidth + 0.5; |
| 111 | 111 | const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; |
| ... | ... | @@ -156,8 +156,8 @@ function ChatThreadRouteView() { |
| 156 | 156 | }); |
| 157 | 157 | const search = Route.useSearch(); |
| 158 | 158 | 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), | |
| 161 | 161 | ); |
| 162 | 162 | const routeThreadExists = threadExists || draftThreadExists; |
| 163 | 163 | const diffOpen = search.diff === "1"; |
apps/web/src/routes/_chat.settings.tsx+50-43| ... | ... | @@ -115,54 +115,62 @@ function SettingsRouteView() { |
| 115 | 115 | }); |
| 116 | 116 | }, [keybindingsConfigPath]); |
| 117 | 117 | |
| 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) => ({ | |
| 138 | 154 | ...existing, |
| 139 | [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, | |
| 155 | [provider]: "", | |
| 140 | 156 | })); |
| 141 | return; | |
| 142 | } | |
| 143 | if (customModels.includes(normalized)) { | |
| 144 | 157 | setCustomModelErrorByProvider((existing) => ({ |
| 145 | 158 | ...existing, |
| 146 | [provider]: "That custom model is already saved.", | |
| 159 | [provider]: null, | |
| 147 | 160 | })); |
| 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 | ); | |
| 161 | 164 | |
| 162 | 165 | const removeCustomModel = useCallback( |
| 163 | 166 | (provider: ProviderKind, slug: string) => { |
| 164 | 167 | 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 | ); | |
| 166 | 174 | setCustomModelErrorByProvider((existing) => ({ |
| 167 | 175 | ...existing, |
| 168 | 176 | [provider]: null, |
| ... | ... | @@ -381,10 +389,9 @@ function SettingsRouteView() { |
| 381 | 389 | variant="outline" |
| 382 | 390 | onClick={() => |
| 383 | 391 | updateSettings( |
| 384 | patchCustomModels( | |
| 385 | provider, | |
| 386 | [...getDefaultCustomModelsForProvider(defaults, provider)], | |
| 387 | ), | |
| 392 | patchCustomModels(provider, [ | |
| 393 | ...getDefaultCustomModelsForProvider(defaults, provider), | |
| 394 | ]), | |
| 388 | 395 | ) |
| 389 | 396 | } |
| 390 | 397 | > |
apps/web/src/session-logic.ts+13-14| ... | ... | @@ -8,7 +8,13 @@ import { |
| 8 | 8 | type TurnId, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | |
| 11 | import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | |
| 11 | import type { | |
| 12 | ChatMessage, | |
| 13 | ProposedPlan, | |
| 14 | SessionPhase, | |
| 15 | ThreadSession, | |
| 16 | TurnDiffSummary, | |
| 17 | } from "./types"; | |
| 12 | 18 | |
| 13 | 19 | export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; |
| 14 | 20 | |
| ... | ... | @@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt( |
| 138 | 144 | return sendStartedAt; |
| 139 | 145 | } |
| 140 | 146 | |
| 141 | function requestKindFromRequestType( | |
| 142 | requestType: unknown, | |
| 143 | ): PendingApproval["requestKind"] | null { | |
| 147 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { | |
| 144 | 148 | switch (requestType) { |
| 145 | 149 | case "command_execution_approval": |
| 146 | 150 | case "exec_command_approval": |
| ... | ... | @@ -333,9 +337,7 @@ export function deriveActivePlanState( |
| 333 | 337 | return null; |
| 334 | 338 | } |
| 335 | 339 | const status = |
| 336 | record.status === "completed" || record.status === "inProgress" | |
| 337 | ? record.status | |
| 338 | : "pending"; | |
| 340 | record.status === "completed" || record.status === "inProgress" ? record.status : "pending"; | |
| 339 | 341 | return { |
| 340 | 342 | step: record.step, |
| 341 | 343 | status, |
| ... | ... | @@ -355,7 +357,9 @@ export function deriveActivePlanState( |
| 355 | 357 | return { |
| 356 | 358 | createdAt: latest.createdAt, |
| 357 | 359 | 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 | : {}), | |
| 359 | 363 | steps, |
| 360 | 364 | }; |
| 361 | 365 | } |
| ... | ... | @@ -487,12 +491,7 @@ function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { |
| 487 | 491 | target.push(normalized); |
| 488 | 492 | } |
| 489 | 493 | |
| 490 | function collectChangedFiles( | |
| 491 | value: unknown, | |
| 492 | target: string[], | |
| 493 | seen: Set<string>, | |
| 494 | depth: number, | |
| 495 | ) { | |
| 494 | function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) { | |
| 496 | 495 | if (depth > 4 || target.length >= 12) { |
| 497 | 496 | return; |
| 498 | 497 | } |
apps/web/src/store.ts+5-2| ... | ... | @@ -124,7 +124,9 @@ function mapProjectsFromReadModel( |
| 124 | 124 | const previousById = new Map(previous.map((project) => [project.id, project] as const)); |
| 125 | 125 | const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const)); |
| 126 | 126 | 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 | ); | |
| 128 | 130 | const persistedOrderByCwd = new Map( |
| 129 | 131 | persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), |
| 130 | 132 | ); |
| ... | ... | @@ -150,7 +152,8 @@ function mapProjectsFromReadModel( |
| 150 | 152 | |
| 151 | 153 | return mappedProjects |
| 152 | 154 | .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); | |
| 154 | 157 | const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined; |
| 155 | 158 | const orderIndex = |
| 156 | 159 | previousIndex ?? |
apps/web/src/terminalStateStore.test.ts+28-9| ... | ... | @@ -14,7 +14,10 @@ describe("terminalStateStore actions", () => { |
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | 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 | ); | |
| 18 | 21 | expect(terminalState).toEqual({ |
| 19 | 22 | terminalOpen: false, |
| 20 | 23 | terminalHeight: 280, |
| ... | ... | @@ -31,7 +34,10 @@ describe("terminalStateStore actions", () => { |
| 31 | 34 | store.setTerminalOpen(THREAD_ID, true); |
| 32 | 35 | store.splitTerminal(THREAD_ID, "terminal-2"); |
| 33 | 36 | |
| 34 | const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID); | |
| 37 | const terminalState = selectThreadTerminalState( | |
| 38 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 39 | THREAD_ID, | |
| 40 | ); | |
| 35 | 41 | expect(terminalState.terminalOpen).toBe(true); |
| 36 | 42 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| 37 | 43 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| ... | ... | @@ -43,7 +49,10 @@ describe("terminalStateStore actions", () => { |
| 43 | 49 | it("creates new terminals in a separate group", () => { |
| 44 | 50 | useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); |
| 45 | 51 | |
| 46 | const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID); | |
| 52 | const terminalState = selectThreadTerminalState( | |
| 53 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 54 | THREAD_ID, | |
| 55 | ); | |
| 47 | 56 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| 48 | 57 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| 49 | 58 | expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2"); |
| ... | ... | @@ -57,12 +66,16 @@ describe("terminalStateStore actions", () => { |
| 57 | 66 | const store = useTerminalStateStore.getState(); |
| 58 | 67 | store.splitTerminal(THREAD_ID, "terminal-2"); |
| 59 | 68 | 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"]); | |
| 63 | 73 | |
| 64 | 74 | 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([]); | |
| 66 | 79 | }); |
| 67 | 80 | |
| 68 | 81 | it("resets to default and clears persisted entry when closing the last terminal", () => { |
| ... | ... | @@ -70,7 +83,10 @@ describe("terminalStateStore actions", () => { |
| 70 | 83 | store.closeTerminal(THREAD_ID, "default"); |
| 71 | 84 | |
| 72 | 85 | 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"]); | |
| 74 | 90 | }); |
| 75 | 91 | |
| 76 | 92 | it("keeps a valid active terminal after closing an active split terminal", () => { |
| ... | ... | @@ -79,7 +95,10 @@ describe("terminalStateStore actions", () => { |
| 79 | 95 | store.splitTerminal(THREAD_ID, "terminal-3"); |
| 80 | 96 | store.closeTerminal(THREAD_ID, "terminal-3"); |
| 81 | 97 | |
| 82 | const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID); | |
| 98 | const terminalState = selectThreadTerminalState( | |
| 99 | useTerminalStateStore.getState().terminalStateByThreadId, | |
| 100 | THREAD_ID, | |
| 101 | ); | |
| 83 | 102 | expect(terminalState.activeTerminalId).toBe("terminal-2"); |
| 84 | 103 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); |
| 85 | 104 | expect(terminalState.terminalGroups).toEqual([ |
apps/web/src/terminalStateStore.ts+28-10| ... | ... | @@ -35,7 +35,10 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 35 | 35 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] { | |
| 38 | function normalizeRunningTerminalIds( | |
| 39 | runningTerminalIds: string[], | |
| 40 | terminalIds: string[], | |
| 41 | ): string[] { | |
| 39 | 42 | if (runningTerminalIds.length === 0) return []; |
| 40 | 43 | const validTerminalIdSet = new Set(terminalIds); |
| 41 | 44 | return [...new Set(runningTerminalIds)] |
| ... | ... | @@ -58,7 +61,10 @@ function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string |
| 58 | 61 | return candidate; |
| 59 | 62 | } |
| 60 | 63 | |
| 61 | function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { | |
| 64 | function findGroupIndexByTerminalId( | |
| 65 | terminalGroups: ThreadTerminalGroup[], | |
| 66 | terminalId: string, | |
| 67 | ): number { | |
| 62 | 68 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); |
| 63 | 69 | } |
| 64 | 70 | |
| ... | ... | @@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina |
| 183 | 189 | ? state.activeTerminalId |
| 184 | 190 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); |
| 185 | 191 | 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 | ) | |
| 187 | 195 | ? state.activeTerminalGroupId |
| 188 | 196 | : null; |
| 189 | 197 | const activeGroupIdFromTerminal = |
| ... | ... | @@ -239,14 +247,16 @@ function upsertTerminalIntoGroups( |
| 239 | 247 | return normalized; |
| 240 | 248 | } |
| 241 | 249 | |
| 242 | const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; | |
| 250 | const terminalIds = isNewTerminal | |
| 251 | ? [...normalized.terminalIds, terminalId] | |
| 252 | : normalized.terminalIds; | |
| 243 | 253 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); |
| 244 | 254 | |
| 245 | 255 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); |
| 246 | 256 | 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); | |
| 250 | 260 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { |
| 251 | 261 | terminalGroups.splice(existingGroupIndex, 1); |
| 252 | 262 | } |
| ... | ... | @@ -266,13 +276,18 @@ function upsertTerminalIntoGroups( |
| 266 | 276 | }); |
| 267 | 277 | } |
| 268 | 278 | |
| 269 | let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); | |
| 279 | let activeGroupIndex = terminalGroups.findIndex( | |
| 280 | (group) => group.id === normalized.activeTerminalGroupId, | |
| 281 | ); | |
| 270 | 282 | if (activeGroupIndex < 0) { |
| 271 | 283 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); |
| 272 | 284 | } |
| 273 | 285 | if (activeGroupIndex < 0) { |
| 274 | 286 | 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 | ); | |
| 276 | 291 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); |
| 277 | 292 | activeGroupIndex = terminalGroups.length - 1; |
| 278 | 293 | } |
| ... | ... | @@ -323,7 +338,10 @@ function newThreadTerminal(state: ThreadTerminalState, terminalId: string): Thre |
| 323 | 338 | return upsertTerminalIntoGroups(state, terminalId, "new"); |
| 324 | 339 | } |
| 325 | 340 | |
| 326 | function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | |
| 341 | function setThreadActiveTerminal( | |
| 342 | state: ThreadTerminalState, | |
| 343 | terminalId: string, | |
| 344 | ): ThreadTerminalState { | |
| 327 | 345 | const normalized = normalizeThreadTerminalState(state); |
| 328 | 346 | if (!normalized.terminalIds.includes(terminalId)) { |
| 329 | 347 | return normalized; |
apps/web/src/wsNativeApi.test.ts+11-10| ... | ... | @@ -12,12 +12,13 @@ import { |
| 12 | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 13 | 13 | |
| 14 | 14 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); |
| 15 | const showContextMenuFallbackMock = vi.fn< | |
| 16 | <T extends string>( | |
| 17 | items: readonly ContextMenuItem<T>[], | |
| 18 | position?: { x: number; y: number }, | |
| 19 | ) => Promise<T | null> | |
| 20 | >(); | |
| 15 | const showContextMenuFallbackMock = | |
| 16 | vi.fn< | |
| 17 | <T extends string>( | |
| 18 | items: readonly ContextMenuItem<T>[], | |
| 19 | position?: { x: number; y: number }, | |
| 20 | ) => Promise<T | null> | |
| 21 | >(); | |
| 21 | 22 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); |
| 22 | 23 | const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( |
| 23 | 24 | (channel, listener) => { |
| ... | ... | @@ -400,10 +401,10 @@ describe("wsNativeApi", () => { |
| 400 | 401 | |
| 401 | 402 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 402 | 403 | 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 | }); | |
| 407 | 408 | |
| 408 | 409 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith( |
| 409 | 410 | [{ id: "delete", label: "Delete", destructive: true }], |
package.json+1| ... | ... | @@ -38,6 +38,7 @@ |
| 38 | 38 | "test": "turbo run test", |
| 39 | 39 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 40 | 40 | "fmt": "oxfmt", |
| 41 | "fmt:check": "oxfmt --check", | |
| 41 | 42 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 42 | 43 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", |
| 43 | 44 | "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 { |
| 8 | 8 | } from "./git"; |
| 9 | 9 | |
| 10 | 10 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); |
| 11 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); | |
| 11 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( | |
| 12 | GitPreparePullRequestThreadInput, | |
| 13 | ); | |
| 12 | 14 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); |
| 13 | 15 | |
| 14 | 16 | describe("GitCreateWorktreeInput", () => { |
packages/contracts/src/git.ts+1-5| ... | ... | @@ -14,11 +14,7 @@ const GitPushStepStatus = Schema.Literals([ |
| 14 | 14 | "skipped_up_to_date", |
| 15 | 15 | ]); |
| 16 | 16 | const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); |
| 17 | const GitPrStepStatus = Schema.Literals([ | |
| 18 | "created", | |
| 19 | "opened_existing", | |
| 20 | "skipped_not_requested", | |
| 21 | ]); | |
| 17 | const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); | |
| 22 | 18 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); |
| 23 | 19 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; |
| 24 | 20 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); |
packages/contracts/src/orchestration.ts+1-3| ... | ... | @@ -266,9 +266,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 266 | 266 | updatedAt: IsoDateTime, |
| 267 | 267 | deletedAt: Schema.NullOr(IsoDateTime), |
| 268 | 268 | messages: Schema.Array(OrchestrationMessage), |
| 269 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( | |
| 270 | Schema.withDecodingDefault(() => []), | |
| 271 | ), | |
| 269 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])), | |
| 272 | 270 | activities: Schema.Array(OrchestrationThreadActivity), |
| 273 | 271 | checkpoints: Schema.Array(OrchestrationCheckpointSummary), |
| 274 | 272 | session: Schema.NullOr(OrchestrationSession), |
packages/contracts/src/project.ts+1-3| ... | ... | @@ -28,9 +28,7 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
| 28 | 28 | |
| 29 | 29 | export const ProjectWriteFileInput = Schema.Struct({ |
| 30 | 30 | 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)), | |
| 34 | 32 | contents: Schema.String, |
| 35 | 33 | }); |
| 36 | 34 | export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type; |
packages/contracts/src/providerRuntime.ts+16-19| ... | ... | @@ -61,12 +61,7 @@ const RuntimeThreadState = Schema.Literals([ |
| 61 | 61 | ]); |
| 62 | 62 | export type RuntimeThreadState = typeof RuntimeThreadState.Type; |
| 63 | 63 | |
| 64 | const RuntimeTurnState = Schema.Literals([ | |
| 65 | "completed", | |
| 66 | "failed", | |
| 67 | "interrupted", | |
| 68 | "cancelled", | |
| 69 | ]); | |
| 64 | const RuntimeTurnState = Schema.Literals(["completed", "failed", "interrupted", "cancelled"]); | |
| 70 | 65 | export type RuntimeTurnState = typeof RuntimeTurnState.Type; |
| 71 | 66 | |
| 72 | 67 | const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]); |
| ... | ... | @@ -573,14 +568,16 @@ const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({ |
| 573 | 568 | type: SessionConfiguredType, |
| 574 | 569 | payload: SessionConfiguredPayload, |
| 575 | 570 | }); |
| 576 | export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type; | |
| 571 | export type ProviderRuntimeSessionConfiguredEvent = | |
| 572 | typeof ProviderRuntimeSessionConfiguredEvent.Type; | |
| 577 | 573 | |
| 578 | 574 | const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ |
| 579 | 575 | ...ProviderRuntimeEventBase.fields, |
| 580 | 576 | type: SessionStateChangedType, |
| 581 | 577 | payload: SessionStateChangedPayload, |
| 582 | 578 | }); |
| 583 | export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type; | |
| 579 | export type ProviderRuntimeSessionStateChangedEvent = | |
| 580 | typeof ProviderRuntimeSessionStateChangedEvent.Type; | |
| 584 | 581 | |
| 585 | 582 | const ProviderRuntimeSessionExitedEvent = Schema.Struct({ |
| 586 | 583 | ...ProviderRuntimeEventBase.fields, |
| ... | ... | @@ -601,14 +598,16 @@ const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({ |
| 601 | 598 | type: ThreadStateChangedType, |
| 602 | 599 | payload: ThreadStateChangedPayload, |
| 603 | 600 | }); |
| 604 | export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type; | |
| 601 | export type ProviderRuntimeThreadStateChangedEvent = | |
| 602 | typeof ProviderRuntimeThreadStateChangedEvent.Type; | |
| 605 | 603 | |
| 606 | 604 | const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({ |
| 607 | 605 | ...ProviderRuntimeEventBase.fields, |
| 608 | 606 | type: ThreadMetadataUpdatedType, |
| 609 | 607 | payload: ThreadMetadataUpdatedPayload, |
| 610 | 608 | }); |
| 611 | export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; | |
| 609 | export type ProviderRuntimeThreadMetadataUpdatedEvent = | |
| 610 | typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; | |
| 612 | 611 | |
| 613 | 612 | const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ |
| 614 | 613 | ...ProviderRuntimeEventBase.fields, |
| ... | ... | @@ -764,7 +763,8 @@ const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({ |
| 764 | 763 | type: UserInputResolvedType, |
| 765 | 764 | payload: UserInputResolvedPayload, |
| 766 | 765 | }); |
| 767 | export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type; | |
| 766 | export type ProviderRuntimeUserInputResolvedEvent = | |
| 767 | typeof ProviderRuntimeUserInputResolvedEvent.Type; | |
| 768 | 768 | |
| 769 | 769 | const ProviderRuntimeTaskStartedEvent = Schema.Struct({ |
| 770 | 770 | ...ProviderRuntimeEventBase.fields, |
| ... | ... | @@ -856,7 +856,8 @@ const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({ |
| 856 | 856 | type: McpOauthCompletedType, |
| 857 | 857 | payload: McpOauthCompletedPayload, |
| 858 | 858 | }); |
| 859 | export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type; | |
| 859 | export type ProviderRuntimeMcpOauthCompletedEvent = | |
| 860 | typeof ProviderRuntimeMcpOauthCompletedEvent.Type; | |
| 860 | 861 | |
| 861 | 862 | const ProviderRuntimeModelReroutedEvent = Schema.Struct({ |
| 862 | 863 | ...ProviderRuntimeEventBase.fields, |
| ... | ... | @@ -877,7 +878,8 @@ const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({ |
| 877 | 878 | type: DeprecationNoticeType, |
| 878 | 879 | payload: DeprecationNoticePayload, |
| 879 | 880 | }); |
| 880 | export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type; | |
| 881 | export type ProviderRuntimeDeprecationNoticeEvent = | |
| 882 | typeof ProviderRuntimeDeprecationNoticeEvent.Type; | |
| 881 | 883 | |
| 882 | 884 | const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ |
| 883 | 885 | ...ProviderRuntimeEventBase.fields, |
| ... | ... | @@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent |
| 969 | 971 | export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; |
| 970 | 972 | |
| 971 | 973 | // Legacy helper aliases retained for adapters/tests. |
| 972 | const ProviderRuntimeToolKind = Schema.Literals([ | |
| 973 | "command", | |
| 974 | "file-read", | |
| 975 | "file-change", | |
| 976 | "other", | |
| 977 | ]); | |
| 974 | const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]); | |
| 978 | 975 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; |
| 979 | 976 | |
| 980 | 977 | export const ProviderRuntimeTurnStatus = RuntimeTurnState; |
packages/shared/src/shell.test.ts+3-1| ... | ... | @@ -5,7 +5,9 @@ import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell"; |
| 5 | 5 | describe("extractPathFromShellOutput", () => { |
| 6 | 6 | it("extracts the path between capture markers", () => { |
| 7 | 7 | 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 | ), | |
| 9 | 11 | ).toBe("/opt/homebrew/bin:/usr/bin"); |
| 10 | 12 | }); |
| 11 | 13 |
scripts/build-desktop-artifact.ts+4-2| ... | ... | @@ -23,8 +23,10 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); |
| 23 | 23 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 24 | 24 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 25 | 25 | ); |
| 26 | const ProductionMacIconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | |
| 27 | path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng), | |
| 26 | const ProductionMacIconSource = Effect.zipWith( | |
| 27 | RepoRoot, | |
| 28 | Effect.service(Path.Path), | |
| 29 | (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng), | |
| 28 | 30 | ); |
| 29 | 31 | const ProductionLinuxIconSource = Effect.zipWith( |
| 30 | 32 | RepoRoot, |
scripts/dev-runner.ts+1-3| ... | ... | @@ -399,9 +399,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 399 | 399 | |
| 400 | 400 | const envOverrides = { |
| 401 | 401 | 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"), | |
| 405 | 403 | logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"), |
| 406 | 404 | }; |
| 407 | 405 |
scripts/release-smoke.ts+29-8| ... | ... | @@ -82,7 +82,12 @@ try { |
| 82 | 82 | |
| 83 | 83 | execFileSync( |
| 84 | 84 | 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 | ], | |
| 86 | 91 | { |
| 87 | 92 | cwd: repoRoot, |
| 88 | 93 | stdio: "inherit", |
| ... | ... | @@ -95,17 +100,33 @@ try { |
| 95 | 100 | }); |
| 96 | 101 | |
| 97 | 102 | 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 | ); | |
| 99 | 108 | |
| 100 | 109 | 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 | ); | |
| 105 | 118 | |
| 106 | 119 | 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 | ); | |
| 109 | 130 | |
| 110 | 131 | console.log("Release smoke checks passed."); |
| 111 | 132 | } finally { |
scripts/update-release-package-versions.ts+2-1| ... | ... | @@ -88,7 +88,8 @@ function parseArgs(argv: ReadonlyArray<string>): { |
| 88 | 88 | return { version, rootDir, writeGithubOutput }; |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); | |
| 91 | const isMain = | |
| 92 | process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); | |
| 92 | 93 | |
| 93 | 94 | if (isMain) { |
| 94 | 95 | const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); |