From a1d3aa3dd004a0eab8e188ef0ffee823f23627ad Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:05:45 +0900 Subject: [PATCH 1/5] build(review-monitor): link integrated CodexKit products --- .../CodexReviewMonitor.xcodeproj/project.pbxproj | 8 ++++++++ .../xcshareddata/swiftpm/Package.resolved | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj index 84624022..4ef45f5b 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ AB663A752F7B9B7F0076BA6D /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = AB663A742F7B9B7F0076BA6D /* AppIcon.icon */; }; + F0A100000000000000000010 /* CodexDataKit in Frameworks */ = {isa = PBXBuildFile; productRef = F0A100000000000000000011 /* CodexDataKit */; }; F0A100000000000000000004 /* ReviewUI in Frameworks */ = {isa = PBXBuildFile; productRef = F0A100000000000000000005 /* ReviewUI */; }; F0A10000000000000000000A /* CodexReviewKit in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000B /* CodexReviewKit */; }; F0A10000000000000000000C /* CodexReviewHost in Frameworks */ = {isa = PBXBuildFile; productRef = F0A10000000000000000000D /* CodexReviewHost */; }; @@ -61,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F0A100000000000000000010 /* CodexDataKit in Frameworks */, F0A100000000000000000004 /* ReviewUI in Frameworks */, F0A10000000000000000000E /* ReviewUIPreviewSupport in Frameworks */, F0A10000000000000000000A /* CodexReviewKit in Frameworks */, @@ -117,6 +119,7 @@ ); name = CodexReviewMonitor; packageProductDependencies = ( + F0A100000000000000000011 /* CodexDataKit */, F0A100000000000000000005 /* ReviewUI */, F0A10000000000000000000F /* ReviewUIPreviewSupport */, F0A10000000000000000000B /* CodexReviewKit */, @@ -563,6 +566,11 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + F0A100000000000000000011 /* CodexDataKit */ = { + isa = XCSwiftPackageProductDependency; + package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */; + productName = CodexDataKit; + }; F0A100000000000000000005 /* ReviewUI */ = { isa = XCSwiftPackageProductDependency; package = F0A100000000000000000002 /* XCLocalSwiftPackageReference "../.." */; diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f4d9a7b..d7d6d61d 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,6 @@ { "originHash" : "c6b46fe80620b9641d4c656454fd12dacf29e40336aaa6becfca5411a05280d3", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "ab025ed970d30c7679913951bdb9fff20a9b77b1" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", From 805b5719b807f035678f723001b0158b77b7fc80 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:11:59 +0900 Subject: [PATCH 2/5] docs(package): document integrated CodexKit products --- .github/workflows/ci.yml | 11 +- Docs/architecture.md | 136 +++++++-------- Docs/codexkit-integration.md | 265 ++++++++++++++++++++++++++++++ Docs/rearchitecture-2026-07-10.md | 8 + README.md | 73 +++++--- 5 files changed, 398 insertions(+), 95 deletions(-) create mode 100644 Docs/codexkit-integration.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b60cc7b..c2d64377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Verify TextTransitions public API run: scripts/verify-text-transitions-api.sh + - name: Run integrated product consumer + run: swift run --package-path Fixtures/CodexReviewKitProductConsumer + package-build: name: Package Test Build (macOS) runs-on: macos-26 @@ -82,6 +85,12 @@ jobs: fail-fast: false matrix: include: + - shard: CodexAppServerKitTests + filter: CodexAppServerKitTests + skip: '' + - shard: CodexDataKitTests + filter: CodexDataKitTests + skip: '' - shard: CodexReviewKitTests filter: CodexReviewKitTests skip: '' @@ -108,7 +117,7 @@ jobs: skip: '' - shard: remaining filter: '' - skip: 'CodexReviewKitTests|CodexReviewAppServerTests|CodexReviewMCPServerTests|CodexReviewHostTests|ReviewUITests' + skip: 'CodexAppServerKitTests|CodexDataKitTests|CodexReviewKitTests|CodexReviewAppServerTests|CodexReviewMCPServerTests|CodexReviewHostTests|ReviewUITests' steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/Docs/architecture.md b/Docs/architecture.md index 0a9124a1..c99d5559 100644 --- a/Docs/architecture.md +++ b/Docs/architecture.md @@ -2,8 +2,12 @@ CodexReviewKit provides ReviewMonitor, a native macOS app for running and observing Codex review. The package is organized around one invariant: -generic Codex data comes from CodexKit, while ReviewMonitor owns only review -product behavior. +generic Codex capabilities are owned by lower-level targets in the same Swift +package, while ReviewMonitor targets own only review product behavior. + +The approved package topology is defined only in +[CodexKit Integration](codexkit-integration.md). The target graph below is its +runtime-oriented ownership view; it must remain consistent with that design. Review content and log content flow in one direction: @@ -39,13 +43,16 @@ This table describes intended ownership. | Target | Responsibility | | --- | --- | | `CodexAppServerKit` | App-server SDK: local `codex app-server` process transport, JSON-RPC client, typed request DTOs, app-server notification schema, and Swift domain APIs for threads, turns, prompts, review sessions, models, accounts, and login. Raw DTOs are not its public boundary. It has no Review, UI, or MCP dependencies | -| `CodexDataKit` | Reusable observable Codex model owners for generic app-server concepts: `CodexModelContainer`, `CodexModelContext`, `CodexFetchRequest`, `CodexFetchedResults`, `CodexWorkspaceGroup`, `CodexWorkspace`, `CodexChat`, chat snapshots, and chat change streams. It lives in the separate CodexKit repository | +| `CodexAppServerKitTesting` | Public deterministic in-memory app-server runtime and typed fixtures for external consumer tests. It depends on `CodexAppServerKit` and has no Review, UI, or MCP dependencies | +| `CodexDataKit` | Reusable observable Codex model owners for generic app-server concepts: `CodexModelContainer`, `CodexModelContext`, `CodexFetchRequest`, `CodexFetchedResults`, `CodexWorkspaceGroup`, `CodexWorkspace`, `CodexChat`, chat snapshots, and chat change streams. It depends on `CodexAppServerKit`, not on Review targets | | `CodexReviewKit` | Review product core: run identity, lifecycle, cancellation, restart/recovery, auth/settings/runtime state, store commands, MCP command state, and ReviewMonitor-specific policies. It has no app-server wire, UI, or MCP dependencies | | `CodexReviewAppServer` | Adapter from `CodexAppServerKit` high-level review sessions into `CodexReviewKit` lifecycle events and cleanup/recovery operations | | `CodexReviewMCPServer` | MCP server and projections over the review store contract plus CodexChat log projections. It owns MCP protocol request/response conversion and the Streamable HTTP endpoint. It has no UI or app-server backend dependency | | `CodexReviewHost` | Runtime composition for ReviewMonitor | | `ReviewUI` | Concrete ReviewMonitor UI: AppKit views/controllers, existing hosted SwiftUI views, sidebar selection/filter/DnD presentation, context menus, and log rendering state | -| `CodexReviewTesting` | Deterministic fake backend, fake JSON-RPC transport, gates, manual clock | +| `ReviewChatLogUI` | Selected-chat log rendering over `CodexDataKit` snapshots and changes | +| `ReviewUIPreviewSupport` | Preview fixtures composed from the public testing runtime and production UI data flow | +| `CodexReviewTesting` | Review-product fake backend, gates, and manual clock; generic app-server test behavior remains in `CodexAppServerKitTesting` | | `TextTransitions` | UI text transition view support | ReviewMonitor is the product entry point. The host target wires the concrete @@ -88,73 +95,63 @@ CodexDataKit model APIs; UI code does not parse app-server wire data. ```mermaid flowchart TB - subgraph AppServerKit["CodexAppServerKit"] - Server["CodexAppServer"] - ReviewSession["CodexReviewSession"] - Client["JSON-RPC client"] - Process["codex app-server"] - end - - subgraph DataKit["CodexDataKit"] - Context["CodexModelContext"] - Fetch["CodexFetchedResults"] - Chat["CodexChat"] - end - - subgraph ReviewCore["CodexReviewKit"] - Store["CodexReviewStore"] - Run["ReviewRunRecord"] - Awaiter["ReviewObservationAwaiter"] - end - - subgraph AppServerAdapter["CodexReviewAppServer"] - Adapter["Review lifecycle adapter"] + App["CodexReviewMonitor app"] + + subgraph Package["CodexReviewKit package"] + Host["CodexReviewHost"] + UI["ReviewUI"] + Preview["ReviewUIPreviewSupport"] + ChatUI["ReviewChatLogUI"] + MCP["CodexReviewMCPServer"] + ReviewAdapter["CodexReviewAppServer"] + ReviewCore["CodexReviewKit"] + ReviewTesting["CodexReviewTesting"] + DataKit["CodexDataKit"] + AppServerTesting["CodexAppServerKitTesting"] + AppServerKit["CodexAppServerKit"] + TextTransitions["TextTransitions"] end - subgraph MCP["CodexReviewMCPServer"] - MCPServer["MCP tools"] - MCPProjection["CodexChat log projection"] - end - - subgraph UI["ReviewUI"] - Sidebar["CodexChat sidebar"] - Detail["CodexChat detail log"] - Presentation["Selection, filters, DnD, context menus"] - end - - subgraph Host["CodexReviewHost"] - Composition["Composition root"] - end - - Server --> Client - ReviewSession --> Client - Client --> Process - Server --> Context - Context --> Fetch - Context --> Chat - ReviewSession --> Adapter - Adapter --> Store - Store --> Run - Store --> Awaiter - Fetch --> Sidebar - Chat --> Detail - Run --> Sidebar - Run --> MCPServer - Chat --> MCPProjection - MCPProjection --> MCPServer - Store --> MCPServer - Presentation --> Store - Presentation --> Chat - Composition --> Server - Composition --> Context - Composition --> Store - Composition --> MCPServer - Composition --> Sidebar + App --> Host + App --> UI + App --> Preview + App --> ReviewCore + Host --> MCP + Host --> ReviewAdapter + Host --> ReviewCore + Host --> DataKit + Host --> AppServerKit + Preview --> UI + Preview --> ReviewCore + Preview --> DataKit + Preview --> AppServerTesting + Preview --> AppServerKit + UI --> ChatUI + UI --> ReviewCore + UI --> DataKit + UI --> AppServerKit + ChatUI --> TextTransitions + ChatUI --> DataKit + ChatUI --> AppServerKit + MCP --> ReviewCore + MCP --> DataKit + MCP --> AppServerKit + ReviewAdapter --> ReviewCore + ReviewAdapter --> DataKit + ReviewAdapter --> AppServerKit + ReviewTesting --> ReviewCore + ReviewTesting --> AppServerTesting + ReviewTesting --> AppServerKit + DataKit --> AppServerKit + AppServerTesting --> AppServerKit ``` -The diagram describes ownership direction, not every SwiftPM dependency. New -code should not move generic Codex model ownership, UI rendering, or MCP -projection responsibilities back into review lifecycle owners. +An arrow means the source target depends on the destination target. External +package dependencies and test targets are omitted. Dependencies point from app +composition and review-specific targets toward lower-level generic targets; +lower-level targets never import Review targets. New code should not move +generic Codex model ownership, UI rendering, or MCP projection responsibilities +back into review lifecycle owners. ## Observation Ownership @@ -314,12 +311,17 @@ Default tests are deterministic and do not start a live `codex app-server`. | Test area | Uses | Verifies | | --- | --- | --- | | `CodexAppServerKitTests` | Fake JSON-RPC transport | Generic app-server handshake, request serialization, retry, notification routing, domain result aggregation, and high-level review stream behavior | +| `CodexDataKitTests` | `CodexAppServerKitTesting` and observable model owners | Query planning, model identity, membership/order, snapshot/change streams, mutations, and context ownership | | `CodexReviewKitTests` | Fake `CodexReviewStoreBackend`, review lifecycle records, and ObservationBridge awaiters | Use-case observation behavior, review/auth/settings state machines, cancellation, restart/recovery, and lifecycle retention | | `CodexReviewAppServerTests` | Fake app-server review sessions or transport as needed | Adapter behavior from high-level review sessions into lifecycle events, cleanup, and recovery | | `CodexReviewMCPServerTests` | Fake review store and CodexChat projections | MCP protocol conversion, response shape, lifecycle projection, and CodexChat log/review projection snapshots | | `CodexReviewHostTests` | Fake runtime dependencies | Composition, startup, shutdown | | `ReviewUITests` | Preview/test monitor backend and CodexChat fixtures | Native UI behavior, CodexChat rendering, context menus, DnD/filter presentation, and user-intent forwarding | +`CodexAppServerKitTests` and `CodexDataKitTests` have dedicated package-test CI +shards. `Fixtures/CodexReviewKitProductConsumer` is a separate package that +imports, links, and runs all three public Codex products without `@testable`. + Forbidden test patterns: - Sleeping to wait for lifecycle progress when an explicit signal can be used. diff --git a/Docs/codexkit-integration.md b/Docs/codexkit-integration.md new file mode 100644 index 00000000..e5f1e363 --- /dev/null +++ b/Docs/codexkit-integration.md @@ -0,0 +1,265 @@ +# CodexKit Integration Design + +| Item | Decision | +| --- | --- | +| Status | **Approved Phase 2 design** | +| Canonical scope | This is the only source of truth for the CodexKit repository/package topology | +| CodexReviewKit base | `e9240c4d1a3fd71a406a76909f78fdac2af911b6` | +| Imported CodexKit source | `ab025ed970d30c7679913951bdb9fff20a9b77b1` | +| Requirements | macOS 26 or later; Swift 6.3 or later | + +This design moves the three CodexKit products into the existing +CodexReviewKit package without changing their module names, public API surface, +owners, or behavior. `Docs/architecture.md` is the derived runtime ownership +view. If another document disagrees about repository or package topology, this +document wins. + +## Scope And Consumer Contract + +The outcome is one repository and one Swift package that distributes +ReviewMonitor plus `CodexAppServerKit`, `CodexDataKit`, and +`CodexAppServerKitTesting`. ReviewMonitor is the known production consumer. The +separate `Fixtures/CodexReviewKitProductConsumer` package is the distribution +contract consumer for all three products; it is not evidence that every unknown +external consumer can accept the new platform floor. + +The integration changes ownership of distribution, versioning, and CI. It does +not redesign runtime behavior, public API, access control, data ownership, or +variant handling. + +## Target Graph And Owners + +An arrow means the source target depends on the destination target. External +dependencies are listed in the owner table rather than in the graph. The graph +is acyclic: app/review targets can depend on generic Codex targets, while generic +targets never depend on review targets. + +```mermaid +flowchart TB + Host["CodexReviewHost"] --> MCP["CodexReviewMCPServer"] + Host --> ReviewAdapter["CodexReviewAppServer"] + Host --> ReviewCore["CodexReviewKit"] + Host --> DataKit["CodexDataKit"] + Host --> AppServerKit["CodexAppServerKit"] + + Preview["ReviewUIPreviewSupport"] --> UI["ReviewUI"] + Preview --> ReviewCore + Preview --> DataKit + Preview --> AppServerTesting["CodexAppServerKitTesting"] + Preview --> AppServerKit + + UI --> ChatUI["ReviewChatLogUI"] + UI --> ReviewCore + UI --> DataKit + UI --> AppServerKit + ChatUI --> TextTransitions["TextTransitions"] + ChatUI --> DataKit + ChatUI --> AppServerKit + + MCP --> ReviewCore + MCP --> DataKit + MCP --> AppServerKit + ReviewAdapter --> ReviewCore + ReviewAdapter --> DataKit + ReviewAdapter --> AppServerKit + + ReviewTesting["CodexReviewTesting"] --> ReviewCore + ReviewTesting --> AppServerTesting + ReviewTesting --> AppServerKit + DataKit --> AppServerKit + AppServerTesting --> AppServerKit +``` + +| Target | Distribution | Owner and direct dependencies | +| --- | --- | --- | +| `CodexAppServerKit` | Public product | Owns the app-server process, transport, protocol/domain conversion, and high-level app-server APIs. It has no internal package dependency | +| `CodexAppServerKitTesting` | Public product | Owns the deterministic in-memory app-server runtime and typed fixtures. It depends on `CodexAppServerKit` | +| `CodexDataKit` | Public product | Owns observable generic Codex models, contexts, queries, membership/order, and snapshot/change streams. It depends on `CodexAppServerKit` and external `AsyncAlgorithms` | +| `CodexReviewKit` | Public product | Owns review-run lifecycle, product policy, commands, auth/settings/runtime state, and MCP command state. Its only package-external implementation dependency is `ObservationBridge` | +| `CodexReviewAppServer` | Internal target | Owns adaptation from high-level app-server review sessions to review lifecycle events. It depends on both generic products and `CodexReviewKit` | +| `CodexReviewMCPServer` | Internal target | Owns MCP protocol conversion, HTTP transport, and CodexChat projections. It depends on the generic products, `CodexReviewKit`, MCP, and SwiftNIO | +| `CodexReviewHost` | Public product | Owns ReviewMonitor runtime composition. It depends on the generic products and review adapter/core/MCP targets | +| `CodexReviewTesting` | Internal target | Owns review-product fakes, gates, and manual clock. It depends on `CodexReviewKit` and both app-server products | +| `ReviewChatLogUI` | Internal target | Owns selected-chat log rendering. It depends on the generic products and `TextTransitions` | +| `ReviewUI` | Public product | Owns ReviewMonitor presentation and user-intent forwarding. It depends on review core, chat-log UI, the generic products, and `ObservationBridge` | +| `ReviewUIPreviewSupport` | Public product | Owns preview composition through the production UI data flow. It depends on ReviewUI/core and all three generic products | +| `TextTransitions` | Public product | Owns text transition rendering and has no internal package dependency | + +The target boundaries are retained because they enforce distinct owners and +dependency direction. Moving repositories does not justify merging them. + +## Public Products And API Compatibility + +Before integration, a consumer resolves a second package and names that package +when selecting products: + +```swift +dependencies: [ + .package( + url: "https://github.com/lynnswap/CodexKit.git", + revision: "ab025ed970d30c7679913951bdb9fff20a9b77b1" + ), +] + +.product(name: "CodexAppServerKit", package: "CodexKit") +.product(name: "CodexDataKit", package: "CodexKit") +.product(name: "CodexAppServerKitTesting", package: "CodexKit") +``` + +After integration, only the dependency URL and package identity change: + +```swift +dependencies: [ + .package( + url: "https://github.com/lynnswap/CodexReviewKit.git", + branch: "main" + ), +] + +.product(name: "CodexAppServerKit", package: "CodexReviewKit") +.product(name: "CodexDataKit", package: "CodexReviewKit") +.product(name: "CodexAppServerKitTesting", package: "CodexReviewKit") +``` + +The imports and consumer code remain unchanged: + +```swift +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +``` + +Product names, module names, and the source-level public API from the imported +CodexKit commit are compatibility invariants. This integration does not add an +umbrella module or re-export one module through another. + +## Access Control + +All `public`, `package`, and `internal` declarations retain the visibility they +have at the imported CodexKit commit. Ordinarily a topology design inventories +and minimizes public declarations. This migration deliberately does not shrink +them because the old repository is already consumable and its complete external +consumer population is unknown. Combining a repository move with a visibility +reduction would make failures impossible to attribute to one compatibility +change. + +`Fixtures/CodexReviewKitProductConsumer` proves that the documented public path +can import, link, and run all three products without `@testable import`. Any +future surface reduction requires a separate API inventory, consumer evidence, +versioning decision, and design gate. + +## Platform Trade-off + +The former CodexKit package declared macOS 15.4. A Swift package has one package +platform floor, so integration adopts CodexReviewKit's macOS 26 requirement for +all products. Swift 6.3 remains unchanged. + +This is an intentional distribution compatibility break for an unknown +consumer that needs macOS 15.4 through 25. Known ReviewMonitor consumers already +require macOS 26. The external fixture also targets macOS 26. The design does not +preserve the older floor with a nested package, wrapper, or duplicate target +graph; existing SHA-pinned consumers can continue resolving the archived +CodexKit repository while they plan a migration. + +## Variation Axes + +Repository placement is not a runtime variation axis. The imported source stays +mechanically identical, so adding a variant continues to use the same owner and +registration point as before integration. + +| Axis | Existing absorber | Integration effect | +| --- | --- | --- | +| Real process versus injected transport | `CodexAppServerKit` configuration/transport boundary | None | +| Observable query, ordering, and mutation policy | `CodexDataKit` model context and query-plan owners | None | +| Live versus deterministic test runtime | `CodexAppServerKit` / `CodexAppServerKitTesting` target boundary | None | +| Generic Codex behavior versus review-product policy | Generic targets / review adapter and core boundary | None | +| App rendering and preview composition | `ReviewUI`, `ReviewChatLogUI`, and `ReviewUIPreviewSupport` | None | + +A new variant must not require a repository-location conditional or a second +dependency path. If it does, this design must be revisited before implementation. + +## Deletion List + +- Delete `dependencies/CodexKit` discovery and the manifest's local-path versus + remote fallback branch. +- Delete `codexKitFallbackRevision`, the remote CodexKit package dependency, and + every `.product(..., package: "CodexKit")` edge in the root manifest. +- Delete CodexKit entries from the root and ReviewMonitor workspace + `Package.resolved` files. Do not retain a pin as an inactive fallback. +- Retire the separately active CodexKit CI/release owner after migration. Its + workflow is not copied wholesale; CodexReviewKit's existing package build, + explicit test shards, external product contract, and release verification own + the imported products. +- Delete README instructions for switching between a local checkout and a + remote revision or advancing a CodexKit pin. + +## Avoided Shapes + +- No nested package at `dependencies/CodexKit` or another in-repository package. + The three modules are root-package targets. +- No Git submodule. Source and history are imported by the migration commit, not + resolved as a second checkout. +- No wrapper or bridge modules around `CodexAppServerKit`, `CodexDataKit`, or + `CodexAppServerKitTesting`; consumers import the existing modules directly. +- No giant target that combines the three generic modules or folds them into + `CodexReviewKit`. Their owners and one-way dependencies remain compiler-enforced. +- No `CodexKit` umbrella product, compatibility re-export, mirror state, or + package-location runtime switch. + +## Test Plan + +1. Root package suites: build all tests once, then run + `CodexAppServerKitTests` and `CodexDataKitTests` as explicit CI shards beside + the existing review/UI shards. Both names are excluded from the `remaining` + shard so ownership cannot depend on incidental discovery. +2. External consumer: CI runs + `swift run --package-path Fixtures/CodexReviewKitProductConsumer`. This clean + package selects all three products, uses only public imports, and completes + through the in-memory runtime without a live `codex`, network, or auth. +3. Full package gate: run + `swift test --build-system swiftbuild --no-parallel` after all branches are + integrated. +4. Xcode consumer: run + `xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor -destination 'platform=macOS,arch=arm64' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO`. +5. Release verification continues to run full package and ReviewMonitor tests; + the first integrated release must use the existing signed/notarized local + archive path before the old repository is archived. + +Default acceptance remains deterministic. It must not launch a live app-server +or require network/auth credentials. + +## Findings And Resolution + +| Finding | Design resolution | Acceptance evidence | +| --- | --- | --- | +| CodexReviewKit can silently drift between a local CodexKit checkout and a reviewed remote revision | One repository commit owns both the generic modules and their consumers; the conditional dependency path is deleted | Root manifest contains no CodexKit dependency or local override; product consumer uses the root package | +| Switching dependency kind and advancing CodexKit causes `Package.resolved` churn in both SwiftPM and Xcode contexts | Remove the CodexKit pin from both resolved files; remaining dependencies resolve once for the single package graph | Root and workspace resolved files contain no `CodexKit` location/identity | +| CodexKit suites would otherwise land accidentally in the large `remaining` CI shard | Give `CodexAppServerKitTests` and `CodexDataKitTests` dedicated matrix entries and exclusions; run the external consumer in the API-contract job | Workflow structure check plus explicit shard/skip assertions | +| Archiving the old repository too early or destructively could break existing SHA-pinned consumers | Archive only after integration verification and preferably an integrated release; preserve the public repository, source, `Package.swift`, `LICENSE`, commits, tags, and branches | Resolve the old exact SHA from a clean consumer before and after archival | +| The package platform floor rises from macOS 15.4 to macOS 26 | State the break in installation requirements and keep the archived exact-SHA path available; do not add a compatibility topology | README requirement, macOS 26 external fixture, package/Xcode gates | + +## Repository Publication And Archive Safety + +Repository archival is a publication operation and is not performed by the +integration code change. Use this sequence: + +1. Merge and verify the CodexReviewKit integration, including the external + consumer and ReviewMonitor tests. +2. Preferably publish and verify the first CodexReviewKit release that contains + all three products. +3. Update the old CodexKit README and repository description to point consumers + to CodexReviewKit. Keep its source, `Package.swift`, `LICENSE`, complete Git + history, tags, and branches intact. +4. Verify a clean consumer can still resolve the old exact SHA, then archive the + public CodexKit repository without renaming, deleting, transferring, or + rewriting it. +5. Repeat the exact-SHA resolution check after archival. + +GitHub documents that archiving makes repository code, commits, tags, branches, +and releases read-only; it does not require deleting them. See +[Archiving repositories](https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories). +Keeping the old URL and objects intact protects existing revision pins while +clearly ending active maintenance in that repository. + +Archiving CodexKit, publishing a release, editing its remote README, and changing +repository settings are explicit non-goals of this branch. diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index 15c4dfb1..9ac7ba2e 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -1,5 +1,13 @@ # CodexKit / CodexReviewKit / MCP dependency rearchitecture design(2026-07-10) +> [!NOTE] +> This document is a historical design baseline. Its separate-repository and +> package-topology decisions are superseded by the approved +> [CodexKit Integration Design](codexkit-integration.md), which is the only +> source of truth for the integrated repository/package topology. The evidence +> and runtime/API decisions recorded here remain historical context unless the +> newer design explicitly supersedes them. + | 項目 | 内容 | |---|---| | Status | **Approved — design gate 承認済み(2026-07-10)** | diff --git a/README.md b/README.md index 4a0412cc..d0395703 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,48 @@ # CodexReviewKit -CodexReviewKit is the native macOS companion app for Codex review. +CodexReviewKit is the native macOS companion app for Codex review. The same +Swift package also distributes reusable app-server, observable model, and test +runtime libraries for macOS apps and tools that work with Codex. Launch `CodexReviewMonitor.app`, register its MCP endpoint with Codex, then run reviews through the `codex_review` tools while the app keeps the review state visible. +## Requirements + +- macOS 26 or later. +- Swift 6.3 or later. +- A local `codex` executable when using ReviewMonitor or a real app-server + process. `CodexAppServerKitTesting` does not launch one. + +## Add The Package + +Add CodexReviewKit as a Swift package dependency: + +```swift +dependencies: [ + .package( + url: "https://github.com/lynnswap/CodexReviewKit.git", + branch: "main" + ), +] +``` + +Then select the library products your target needs: + +```swift +.product(name: "CodexAppServerKit", package: "CodexReviewKit"), +.product(name: "CodexDataKit", package: "CodexReviewKit"), +.product(name: "CodexAppServerKitTesting", package: "CodexReviewKit"), +``` + +- `CodexAppServerKit` provides domain APIs for app-server connections, threads, + responses, reviews, models, accounts, and login. +- `CodexDataKit` provides observable app-server-backed model objects and fetch + APIs on top of `CodexAppServerKit`. +- `CodexAppServerKitTesting` provides a deterministic in-memory app-server test + runtime without launching a real process. + ## Quick Start 1. Launch `CodexReviewMonitor.app`. @@ -63,10 +100,13 @@ await appServer.close() only ReviewMonitor-specific `review/start` orchestration and review event conversion. -See [CodexAppServerKit README][codex-app-server-kit-readme] for the +See the [CodexAppServerKit README](Sources/CodexAppServerKit/README.md) for the standalone SDK surface, including thread-level streams for messages, transcripts, log entries, and in-flight response controls such as steer, queue, -and interrupt. +and interrupt. The [CodexDataKit README](Sources/CodexDataKit/README.md) covers +model containers, fetch requests, sectioning, SwiftUI queries, and ownership. +The [`CodexReviewKitProductConsumer`](Fixtures/CodexReviewKitProductConsumer) +fixture builds, links, and runs all three products without `@testable import`. ## Timeout Setup @@ -100,32 +140,13 @@ This Claude Code setting is process-wide. It is not scoped to the `codex_review` MCP server, so the same idle timeout applies to all MCP tools used by that Claude Code session. -## Local CodexKit Development - -`Package.swift` uses a local `dependencies/CodexKit` checkout when that -directory contains a `Package.swift`. If the local checkout is absent, SwiftPM -resolves `CodexKit` from the pinned fallback revision in `Package.swift`. -Update that revision to a reviewed CodexKit `main` commit whenever -CodexReviewKit adopts new CodexKit APIs. - -```bash -mkdir -p dependencies -git clone git@github.com:lynnswap/CodexKit.git dependencies/CodexKit -swift test --build-system swiftbuild --no-parallel -``` - -After creating or removing `dependencies/CodexKit`, run SwiftPM with manifest -caching disabled once if resolution still points at the previous dependency -kind: - -```bash -swift package --manifest-cache none resolve -``` - ## More Detail - [Architecture](Docs/architecture.md): package boundaries, runtime flow, and test responsibilities. +- [CodexKit integration design](Docs/codexkit-integration.md): the canonical + target topology, compatibility contract, deletion scope, and migration test + plan. - [MCP reference](Docs/mcp.md): tool schemas, discovery resources, session behavior, and runtime files. @@ -156,5 +177,3 @@ The release verification workflow also requires the repository variable Developer ID Application certificate used by `--signing-identity`. The workflow will not publish the draft release unless the uploaded DMG and contained app are signed and notarized for that Team ID. - -[codex-app-server-kit-readme]: https://github.com/lynnswap/CodexKit/blob/main/Sources/CodexAppServerKit/README.md From 2fea5d960fd756ab74b9082036581ccab73e458f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:13:54 +0900 Subject: [PATCH 3/5] docs(package): clarify internal collision cleanup --- Docs/codexkit-integration.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/Docs/codexkit-integration.md b/Docs/codexkit-integration.md index e5f1e363..6b72fb72 100644 --- a/Docs/codexkit-integration.md +++ b/Docs/codexkit-integration.md @@ -24,8 +24,9 @@ contract consumer for all three products; it is not evidence that every unknown external consumer can accept the new platform floor. The integration changes ownership of distribution, versioning, and CI. It does -not redesign runtime behavior, public API, access control, data ownership, or -variant handling. +not redesign runtime behavior, public API, data ownership, or variant handling. +Implementation visibility can change only under the collision rule in +"Access Control" below. ## Target Graph And Owners @@ -135,13 +136,21 @@ umbrella module or re-export one module through another. ## Access Control -All `public`, `package`, and `internal` declarations retain the visibility they -have at the imported CodexKit commit. Ordinarily a topology design inventories -and minimizes public declarations. This migration deliberately does not shrink -them because the old repository is already consumable and its complete external -consumer population is unknown. Combining a repository move with a visibility -reduction would make failures impossible to attribute to one compatibility -change. +Product names, module names, and public API visibility remain unchanged from the +imported CodexKit commit. The migration deliberately does not shrink that +surface because the old repository is already consumable and its complete +external consumer population is unknown. Combining a repository move with a +public visibility reduction would make failures impossible to attribute to one +compatibility change. + +`package` and `internal` implementation declarations may be reduced or renamed +only when the single-package integration reveals a same-package collision, +repo-wide search proves the declaration has zero consumers, and owner tests +preserve behavior. This permits deleting the unused package-only +`enqueueAccount(CodexAccount?)` overload instead of annotating unrelated call +sites to select the public test-fixture overload. It does not permit Review +targets to begin consuming APIs that were package-only inside CodexKit; the +repository merge must not weaken the existing target-owner boundary. `Fixtures/CodexReviewKitProductConsumer` proves that the documented public path can import, link, and run all three products without `@testable import`. Any @@ -164,8 +173,9 @@ CodexKit repository while they plan a migration. ## Variation Axes Repository placement is not a runtime variation axis. The imported source stays -mechanically identical, so adding a variant continues to use the same owner and -registration point as before integration. +behaviorally identical at its public boundary; only the evidence-backed +package/internal collision cleanup defined above may differ. Adding a variant +continues to use the same owner and registration point as before integration. | Axis | Existing absorber | Integration effect | | --- | --- | --- | @@ -234,6 +244,7 @@ or require network/auth credentials. | --- | --- | --- | | CodexReviewKit can silently drift between a local CodexKit checkout and a reviewed remote revision | One repository commit owns both the generic modules and their consumers; the conditional dependency path is deleted | Root manifest contains no CodexKit dependency or local override; product consumer uses the root package | | Switching dependency kind and advancing CodexKit causes `Package.resolved` churn in both SwiftPM and Xcode contexts | Remove the CodexKit pin from both resolved files; remaining dependencies resolve once for the single package graph | Root and workspace resolved files contain no `CodexKit` location/identity | +| Same-package lookup can expose dead package-only overloads that were isolated by the old package boundary | Delete or rename only the colliding declaration after repo-wide zero-use evidence; do not annotate consumer call sites or let Review targets adopt old package-only APIs | Zero-use search plus owner and full-package tests | | CodexKit suites would otherwise land accidentally in the large `remaining` CI shard | Give `CodexAppServerKitTests` and `CodexDataKitTests` dedicated matrix entries and exclusions; run the external consumer in the API-contract job | Workflow structure check plus explicit shard/skip assertions | | Archiving the old repository too early or destructively could break existing SHA-pinned consumers | Archive only after integration verification and preferably an integrated release; preserve the public repository, source, `Package.swift`, `LICENSE`, commits, tags, and branches | Resolve the old exact SHA from a clean consumer before and after archival | | The package platform floor rises from macOS 15.4 to macOS 26 | State the break in installation requirements and keep the archived exact-SHA path available; do not add a compatibility topology | README requirement, macOS 26 external fixture, package/Xcode gates | From 058ccde4929c1178084c726d5c04cb4af2fa6bc8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:28:40 +0900 Subject: [PATCH 4/5] refactor(package): integrate CodexKit products --- .gitignore | 2 +- .../Package.resolved | 86 + .../Package.swift | 33 + .../CodexReviewKitProductConsumer/main.swift | 179 + Package.resolved | 10 +- Package.swift | 125 +- .../CodexAppServerKit/AccountEventHub.swift | 421 + .../CodexAppServerKit/AppServerClient.swift | 676 + .../AppServerConnection.swift | 235 + .../AppServerNotificationDecoder.swift | 1559 ++ .../AppServerProcessTransport.swift | 1816 ++ .../CodexAppServerKit/AppServerRequests.swift | 2808 +++ .../CodexAppServerKit/CodexAppServer.swift | 1548 ++ .../CodexAppServerNotificationRouter.swift | 1301 ++ .../CodexAppServerRequest.swift | 657 + .../CodexAppServerRequestCodec.swift | 143 + .../CodexAppServerKit/CodexDomainTypes.swift | 3468 ++++ .../CodexAppServerKit/CodexItemReducer.swift | 376 + .../CodexThreadOperations.swift | 997 + .../CodexTurnSequences.swift | 944 + .../CodexTurnSnapshotReducer.swift | 291 + .../ConnectionEventHub.swift | 569 + .../ConnectionSupervisor.swift | 326 + .../ConnectionTerminationArbiter.swift | 161 + .../InterruptRaceResolver.swift | 75 + Sources/CodexAppServerKit/JSONRPC.swift | 150 + .../JSONRPCEnvelopeCodec.swift | 220 + .../JSONRPCInboundFrameMailbox.swift | 337 + Sources/CodexAppServerKit/LoginRegistry.swift | 446 + Sources/CodexAppServerKit/README.md | 576 + .../CodexAppServerKit/RequestSerializer.swift | 567 + .../ReviewRestartCoordinator.swift | 1157 ++ .../ServerRequestRegistry.swift | 346 + .../CodexAppServerKit/ThreadEventHub.swift | 1704 ++ .../CodexAppServerKit/TurnReplayCore.swift | 891 + .../CodexAppServerKit/TurnReplayStore.swift | 986 + .../CodexAppServerTestConnectionHarness.swift | 126 + ...odexAppServerTestNotificationEmitter.swift | 963 + .../CodexAppServerTestResponseFixtures.swift | 716 + .../CodexAppServerTestRuntime.swift | 2734 +++ ...exAppServerTestServerRequestInjector.swift | 134 + .../CodexAppServerTestStoredThread.swift | 954 + .../CodexAppServerTestTurn.swift | 152 + .../CodexDataKit/CodexAsyncStreamRelay.swift | 95 + .../CodexDataKit/CodexChatObservation.swift | 826 + Sources/CodexDataKit/CodexChatUpdate.swift | 72 + Sources/CodexDataKit/CodexDataPhase.swift | 43 + Sources/CodexDataKit/CodexFetchRequest.swift | 1647 ++ .../CodexFetchedResultsController.swift | 418 + Sources/CodexDataKit/CodexModel.swift | 3355 ++++ Sources/CodexDataKit/CodexModelActor.swift | 48 + Sources/CodexDataKit/CodexModelContext.swift | 3614 ++++ Sources/CodexDataKit/CodexQuery.swift | 170 + .../CodexDataKit/CodexThreadQueryPlan.swift | 1472 ++ .../FetchedResultsLoadCoordinator.swift | 122 + Sources/CodexDataKit/README.md | 346 + .../AppServerNotificationDecoderTests.swift | 520 + .../CodexAppServerKitTests.swift | 8877 +++++++++ .../CodexAppServerRequestCodecTests.swift | 374 + ...ppServerTestNotificationEmitterTests.swift | 24 + .../CodexAppServerTestStoredThreadTests.swift | 976 + .../CodexItemReducerTests.swift | 550 + .../CodexTurnSnapshotReducerTests.swift | 207 + .../ConnectionEventHubTests.swift | 546 + .../ConnectionLifecycleTests.swift | 715 + .../InterruptRaceResolverTests.swift | 133 + .../JSONRPCEnvelopeCodecTests.swift | 87 + .../ServerRequestRegistryTests.swift | 689 + .../TerminalLifecycleProofTests.swift | 363 + .../ThreadEventHubTests.swift | 1138 ++ .../TurnReplayCoreTests.swift | 356 + .../TurnReplayRouterTests.swift | 213 + .../TurnReplayStoreTests.swift | 925 + .../CodexChatObservationChannelTests.swift | 295 + .../CodexChatObservationMulticastTests.swift | 854 + .../CodexDataKitTests/CodexDataKitTests.swift | 16061 ++++++++++++++++ .../CodexItemIdentityTests.swift | 197 + 77 files changed, 78245 insertions(+), 48 deletions(-) create mode 100644 Fixtures/CodexReviewKitProductConsumer/Package.resolved create mode 100644 Fixtures/CodexReviewKitProductConsumer/Package.swift create mode 100644 Fixtures/CodexReviewKitProductConsumer/Sources/CodexReviewKitProductConsumer/main.swift create mode 100644 Sources/CodexAppServerKit/AccountEventHub.swift create mode 100644 Sources/CodexAppServerKit/AppServerClient.swift create mode 100644 Sources/CodexAppServerKit/AppServerConnection.swift create mode 100644 Sources/CodexAppServerKit/AppServerNotificationDecoder.swift create mode 100644 Sources/CodexAppServerKit/AppServerProcessTransport.swift create mode 100644 Sources/CodexAppServerKit/AppServerRequests.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServer.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServerRequest.swift create mode 100644 Sources/CodexAppServerKit/CodexAppServerRequestCodec.swift create mode 100644 Sources/CodexAppServerKit/CodexDomainTypes.swift create mode 100644 Sources/CodexAppServerKit/CodexItemReducer.swift create mode 100644 Sources/CodexAppServerKit/CodexThreadOperations.swift create mode 100644 Sources/CodexAppServerKit/CodexTurnSequences.swift create mode 100644 Sources/CodexAppServerKit/CodexTurnSnapshotReducer.swift create mode 100644 Sources/CodexAppServerKit/ConnectionEventHub.swift create mode 100644 Sources/CodexAppServerKit/ConnectionSupervisor.swift create mode 100644 Sources/CodexAppServerKit/ConnectionTerminationArbiter.swift create mode 100644 Sources/CodexAppServerKit/InterruptRaceResolver.swift create mode 100644 Sources/CodexAppServerKit/JSONRPC.swift create mode 100644 Sources/CodexAppServerKit/JSONRPCEnvelopeCodec.swift create mode 100644 Sources/CodexAppServerKit/JSONRPCInboundFrameMailbox.swift create mode 100644 Sources/CodexAppServerKit/LoginRegistry.swift create mode 100644 Sources/CodexAppServerKit/README.md create mode 100644 Sources/CodexAppServerKit/RequestSerializer.swift create mode 100644 Sources/CodexAppServerKit/ReviewRestartCoordinator.swift create mode 100644 Sources/CodexAppServerKit/ServerRequestRegistry.swift create mode 100644 Sources/CodexAppServerKit/ThreadEventHub.swift create mode 100644 Sources/CodexAppServerKit/TurnReplayCore.swift create mode 100644 Sources/CodexAppServerKit/TurnReplayStore.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestConnectionHarness.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestNotificationEmitter.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestResponseFixtures.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestServerRequestInjector.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestStoredThread.swift create mode 100644 Sources/CodexAppServerKitTesting/CodexAppServerTestTurn.swift create mode 100644 Sources/CodexDataKit/CodexAsyncStreamRelay.swift create mode 100644 Sources/CodexDataKit/CodexChatObservation.swift create mode 100644 Sources/CodexDataKit/CodexChatUpdate.swift create mode 100644 Sources/CodexDataKit/CodexDataPhase.swift create mode 100644 Sources/CodexDataKit/CodexFetchRequest.swift create mode 100644 Sources/CodexDataKit/CodexFetchedResultsController.swift create mode 100644 Sources/CodexDataKit/CodexModel.swift create mode 100644 Sources/CodexDataKit/CodexModelActor.swift create mode 100644 Sources/CodexDataKit/CodexModelContext.swift create mode 100644 Sources/CodexDataKit/CodexQuery.swift create mode 100644 Sources/CodexDataKit/CodexThreadQueryPlan.swift create mode 100644 Sources/CodexDataKit/FetchedResultsLoadCoordinator.swift create mode 100644 Sources/CodexDataKit/README.md create mode 100644 Tests/CodexAppServerKitTests/AppServerNotificationDecoderTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexAppServerRequestCodecTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexAppServerTestNotificationEmitterTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexAppServerTestStoredThreadTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexItemReducerTests.swift create mode 100644 Tests/CodexAppServerKitTests/CodexTurnSnapshotReducerTests.swift create mode 100644 Tests/CodexAppServerKitTests/ConnectionEventHubTests.swift create mode 100644 Tests/CodexAppServerKitTests/ConnectionLifecycleTests.swift create mode 100644 Tests/CodexAppServerKitTests/InterruptRaceResolverTests.swift create mode 100644 Tests/CodexAppServerKitTests/JSONRPCEnvelopeCodecTests.swift create mode 100644 Tests/CodexAppServerKitTests/ServerRequestRegistryTests.swift create mode 100644 Tests/CodexAppServerKitTests/TerminalLifecycleProofTests.swift create mode 100644 Tests/CodexAppServerKitTests/ThreadEventHubTests.swift create mode 100644 Tests/CodexAppServerKitTests/TurnReplayCoreTests.swift create mode 100644 Tests/CodexAppServerKitTests/TurnReplayRouterTests.swift create mode 100644 Tests/CodexAppServerKitTests/TurnReplayStoreTests.swift create mode 100644 Tests/CodexDataKitTests/CodexChatObservationChannelTests.swift create mode 100644 Tests/CodexDataKitTests/CodexChatObservationMulticastTests.swift create mode 100644 Tests/CodexDataKitTests/CodexDataKitTests.swift create mode 100644 Tests/CodexDataKitTests/CodexItemIdentityTests.swift diff --git a/.gitignore b/.gitignore index 86e0893f..1d821073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -/.build +.build/ /dist/ /release/ /Packages diff --git a/Fixtures/CodexReviewKitProductConsumer/Package.resolved b/Fixtures/CodexReviewKitProductConsumer/Package.resolved new file mode 100644 index 00000000..487acaba --- /dev/null +++ b/Fixtures/CodexReviewKitProductConsumer/Package.resolved @@ -0,0 +1,86 @@ +{ + "originHash" : "7a1faa0e52c93012174980e7fb0218c00fad683ad2e6c9c44839ec3a6692bea3", + "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "observationbridge", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/ObservationBridge.git", + "state" : { + "revision" : "4f046ab5862e5d98687b03f9e522e6de48b7efca", + "version" : "0.12.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/swift-sdk.git", + "state" : { + "revision" : "fae7761fd5d257b24e1d9c49c6dc121e188e0d9b" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "704705c5c51156ede21172a38654d522ce487074", + "version" : "1.8.0" + } + } + ], + "version" : 3 +} diff --git a/Fixtures/CodexReviewKitProductConsumer/Package.swift b/Fixtures/CodexReviewKitProductConsumer/Package.swift new file mode 100644 index 00000000..c4b69afc --- /dev/null +++ b/Fixtures/CodexReviewKitProductConsumer/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.3 + +import PackageDescription + +let package = Package( + name: "CodexReviewKitProductConsumer", + platforms: [ + .macOS(.v26), + ], + products: [ + .executable( + name: "CodexReviewKitProductConsumer", + targets: ["CodexReviewKitProductConsumer"] + ), + ], + dependencies: [ + .package(path: "../.."), + ], + targets: [ + .executableTarget( + name: "CodexReviewKitProductConsumer", + dependencies: [ + .product(name: "CodexAppServerKit", package: "CodexReviewKit"), + .product(name: "CodexAppServerKitTesting", package: "CodexReviewKit"), + .product(name: "CodexDataKit", package: "CodexReviewKit"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + ] + ), + ] +) diff --git a/Fixtures/CodexReviewKitProductConsumer/Sources/CodexReviewKitProductConsumer/main.swift b/Fixtures/CodexReviewKitProductConsumer/Sources/CodexReviewKitProductConsumer/main.swift new file mode 100644 index 00000000..e43cec0e --- /dev/null +++ b/Fixtures/CodexReviewKitProductConsumer/Sources/CodexReviewKitProductConsumer/main.swift @@ -0,0 +1,179 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Foundation + +@main +struct CodexReviewKitProductConsumer { + @MainActor + static func main() async throws { + let workspace = URL( + fileURLWithPath: "/tmp/codex-reviewkit-product-consumer", + isDirectory: true + ) + let assistant = try CodexAppServerTestItem.agentMessage( + id: "review_rollout_assistant", + text: "No findings." + ) + let turn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-fixture", + state: .completed, + items: [assistant.domainProjection] + ), + items: [assistant] + ) + let storedThread = try CodexAppServerTestStoredThread( + snapshot: .init( + id: "thread-fixture", + workspace: workspace, + name: "External product fixture", + preview: "No findings.", + modelProvider: "openai", + source: .subAgent(.review), + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + status: .idle, + ephemeral: false, + turns: [turn.snapshot] + ), + turns: [turn], + metadata: .init( + sessionID: "fixture-session", + parentThreadID: "fixture-parent-thread", + cliVersion: "fixture-cli", + source: .subAgentReview, + gitInfo: .init( + sha: "0123456789abcdef", + branch: "feature/thread-provenance", + originURL: "git@github.com:lynnswap/CodexReviewKit.git" + ) + ), + runtimeMetadata: .init( + model: "gpt-5-codex", + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .workspaceWrite( + writableRoots: [workspace], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ), + activePermissionProfile: nil, + reasoningEffort: .high, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) + let deadlineClock = CodexAppServerTestDeadlineClock() + let configuration = CodexAppServer.Configuration( + serverRequestHandler: { request in + switch request { + case .commandExecutionApproval(let approval): + precondition(approval.threadID.isEmpty == false) + return .approval(.accept) + case .userInput(let prompt): + return .userInput(.init(answers: prompt.questions.reduce(into: [:]) { + $0[$1.id] = .init(answers: []) + })) + default: + return try await CodexAppServer.Configuration + .defaultServerRequestHandler(request: request) + } + } + ) + let runtime = try await CodexAppServerTestRuntime.start( + threads: [storedThread], + configuration: configuration, + deadlineClock: deadlineClock + ) + + do { + precondition(runtime.deadlineClock === deadlineClock) + try await runtime.transport.enqueueAPIKeyLogin() + try await runtime.server.login(apiKey: "fixture-api-key") + let loginRequests = await runtime.transport.recordedRequests(for: .accountLoginStart) + precondition(loginRequests.count == 1) + + let configURL = workspace.appendingPathComponent("config.toml") + let configLayer = try CodexAppServerTestConfigurationLayerMetadata( + source: .user(file: configURL, profile: nil), + version: "fixture-config-v1" + ) + let configRead = try CodexAppServerTestConfigurationReadResult( + configuration: .init(model: "gpt-5-codex"), + origins: ["model": configLayer], + layers: [try .init( + metadata: configLayer, + configuration: .object(["model": .string("gpt-5-codex")]) + )] + ) + try await runtime.transport.enqueueConfiguration(configRead) + let configuration = try await runtime.server.configuration() + precondition(configuration == configRead.configuration) + + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + let reviewSourceKind: CodexThreadSourceKind? = .subAgentReview + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: #Predicate { chat in + chat.isArchived == false && chat.sourceKind == reviewSourceKind + } + )) + precondition(chats.map(\.id) == [CodexThreadID(rawValue: "thread-fixture")]) + + let chat = chats[0] + precondition(chat.sessionID == "fixture-session") + precondition(chat.parentThreadID == "fixture-parent-thread") + precondition(chat.source == .subAgent(.review)) + precondition(chat.sourceKind == .subAgentReview) + precondition(chat.gitInfo?.branch == "feature/thread-provenance") + precondition(chat.gitInfo?.sha == "0123456789abcdef") + precondition(chat.gitInfo?.originURL == "git@github.com:lynnswap/CodexReviewKit.git") + try await context.refresh(chat, includeTurns: true) + let item = chat.items(in: "turn-fixture")[0] + precondition(item.origin == .reviewRolloutAssistant) + precondition(item.semanticRelation == .companionOf(.exitedReviewMode)) + + let thread = try await runtime.server.resumeThread("thread-fixture") + _ = try await thread.read(includeTurns: true) + let requests = await runtime.transport.recordedRequests(for: .threadRead) + guard let lastRequest = requests.last, + case .threadRead(let threadID, let includeTurns) = lastRequest.request, + threadID == "thread-fixture", + includeTurns + else { + preconditionFailure("Expected a semantic thread-read request.") + } + + let fakeRestartToken = CodexReviewRestartToken( + id: "fixture-restart-token", + interruptedIdentity: .init( + threadID: "fixture-source-thread", + turnID: "fixture-turn" + ) + ) + let discarded = await runtime.server.discardPreparedReviewRestart(fakeRestartToken) + precondition(discarded.isEmpty) + let discardedAll = await runtime.server.discardAllPreparedReviewRestarts() + precondition(discardedAll.isEmpty) + + let cleanup = await runtime.server.cleanupReview(.init( + threadID: "thread-fixture", + turnID: "turn-fixture" + )) + precondition(cleanup.attemptedThreadIDs == ["thread-fixture"]) + precondition(cleanup.failures.isEmpty) + } catch { + await runtime.close() + throw error + } + + await runtime.close() + } +} diff --git a/Package.resolved b/Package.resolved index 2cca3630..3dc52739 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,6 @@ { - "originHash" : "70ce22664981eba030baa03c4d4ef13bee21abf7e2f99ad31df38675cbb241d2", + "originHash" : "e64b4c51c05f762b778f36a7fcff26834a31424abb07eee8f8173ea2cbcf725b", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "ab025ed970d30c7679913951bdb9fff20a9b77b1" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 00dca8fa..91535b9f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,25 +1,25 @@ // swift-tools-version: 6.3 -import Foundation import PackageDescription -let packageDirectory = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() -let localCodexKitPath = packageDirectory - .appendingPathComponent("dependencies/CodexKit", isDirectory: true) - .path -let codexKitFallbackRevision = "ab025ed970d30c7679913951bdb9fff20a9b77b1" -let codexKitDependency: Package.Dependency = - FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") - ? .package(path: localCodexKitPath) - : .package(url: "https://github.com/lynnswap/CodexKit.git", revision: codexKitFallbackRevision) - let package = Package( name: "CodexReviewKit", platforms: [ .macOS(.v26), ], products: [ + .library( + name: "CodexAppServerKit", + targets: ["CodexAppServerKit"] + ), + .library( + name: "CodexAppServerKitTesting", + targets: ["CodexAppServerKitTesting"] + ), + .library( + name: "CodexDataKit", + targets: ["CodexDataKit"] + ), .library( name: "CodexReviewKit", targets: ["CodexReviewKit"] @@ -48,9 +48,37 @@ let package = Package( ), .package(url: "https://github.com/apple/swift-nio.git", from: "2.97.1"), .package(url: "https://github.com/lynnswap/ObservationBridge.git", .upToNextMinor(from: "0.12.0")), - codexKitDependency, + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.0"), ], targets: [ + .target( + name: "CodexAppServerKit", + exclude: ["README.md"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], + ), + .target( + name: "CodexAppServerKitTesting", + dependencies: [ + "CodexAppServerKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], + ), + .target( + name: "CodexDataKit", + dependencies: [ + "CodexAppServerKit", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ], + exclude: ["README.md"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + ], + ), .target( name: "CodexReviewKit", dependencies: [ @@ -63,8 +91,8 @@ let package = Package( .target( name: "CodexReviewAppServer", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", "CodexReviewKit", ], swiftSettings: [ @@ -74,8 +102,8 @@ let package = Package( .target( name: "CodexReviewMCPServer", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", "CodexReviewKit", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), @@ -89,8 +117,8 @@ let package = Package( .target( name: "CodexReviewHost", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", "CodexReviewKit", "CodexReviewAppServer", "CodexReviewMCPServer", @@ -102,8 +130,8 @@ let package = Package( .target( name: "CodexReviewTesting", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexAppServerKit", + "CodexAppServerKitTesting", "CodexReviewKit", ], swiftSettings: [ @@ -115,8 +143,8 @@ let package = Package( dependencies: [ "CodexReviewKit", "ReviewChatLogUI", - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ @@ -127,8 +155,8 @@ let package = Package( name: "ReviewChatLogUI", dependencies: [ "TextTransitions", - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -137,9 +165,9 @@ let package = Package( .target( name: "ReviewUIPreviewSupport", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", + "CodexAppServerKitTesting", "CodexReviewKit", "ReviewUI", ], @@ -153,6 +181,27 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .testTarget( + name: "CodexAppServerKitTests", + dependencies: [ + "CodexAppServerKit", + "CodexAppServerKitTesting", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], + ), + .testTarget( + name: "CodexDataKitTests", + dependencies: [ + "CodexDataKit", + "CodexAppServerKitTesting", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + ], + ), .testTarget( name: "CodexReviewKitTests", dependencies: ["CodexReviewKit", "CodexReviewTesting"], @@ -163,9 +212,9 @@ let package = Package( .testTarget( name: "CodexReviewAppServerTests", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexAppServerKitTesting", + "CodexDataKit", "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting", @@ -177,8 +226,8 @@ let package = Package( .testTarget( name: "CodexReviewMCPServerTests", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", "CodexReviewMCPServer", "CodexReviewTesting", .product(name: "NIOCore", package: "swift-nio"), @@ -190,8 +239,8 @@ let package = Package( .testTarget( name: "CodexReviewHostTests", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexAppServerKit", + "CodexAppServerKitTesting", "CodexReviewAppServer", "CodexReviewHost", "CodexReviewTesting", @@ -203,9 +252,9 @@ let package = Package( .testTarget( name: "ReviewUITests", dependencies: [ - .product(name: "CodexAppServerKit", package: "CodexKit"), - .product(name: "CodexDataKit", package: "CodexKit"), - .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + "CodexAppServerKit", + "CodexDataKit", + "CodexAppServerKitTesting", "CodexReviewKit", "CodexReviewTesting", "ReviewChatLogUI", diff --git a/Sources/CodexAppServerKit/AccountEventHub.swift b/Sources/CodexAppServerKit/AccountEventHub.swift new file mode 100644 index 00000000..45cc1067 --- /dev/null +++ b/Sources/CodexAppServerKit/AccountEventHub.swift @@ -0,0 +1,421 @@ +import Foundation +import Synchronization + +package actor AccountEventHub { + package enum Mutation: Equatable, Sendable { + case updated(AppServerNotificationDecoder.AccountUpdate) + case rateLimitsUpdated(AppServerAPI.Account.RateLimits.Snapshot) + } + + private var rateLimitsResponse: AppServerAPI.Account.RateLimits.Response? + private let subscriptionRegistry = AccountEventSubscriptionRegistry() + + package init() {} + + package func events() -> CodexAccountEvents { + subscriptionRegistry.makeEvents() + } + + package var subscriberCountForTesting: Int { + subscriptionRegistry.count + } + + package func replaceRateLimits( + with response: AppServerAPI.Account.RateLimits.Response + ) { + rateLimitsResponse = response + } + + package func apply(_ mutation: Mutation) { + guard subscriptionRegistry.isFinished == false else { + return + } + switch mutation { + case .updated: + yield(.accountUpdated) + case .rateLimitsUpdated(let update): + let base = rateLimitsResponse ?? .init(rateLimits: update) + let merged = base.merging(update) + rateLimitsResponse = merged + guard AppServerAPI.Account.RateLimits.Response.isCodexRateLimit(update.limitID) else { + return + } + yield(.rateLimitsUpdated(.init(appServer: merged))) + } + } + + package func finish(throwing error: CodexAppServerError) { + subscriptionRegistry.finish(throwing: error) + } + + private func yield(_ event: CodexAccountEvent) { + subscriptionRegistry.yield(event) + } +} + +public struct CodexAccountEvents: AsyncSequence, Sendable { + public typealias Element = CodexAccountEvent + + private let channel: AccountEventSubscriberChannel + private let cancellation: AccountEventSubscriptionCancellation + + fileprivate init( + channel: AccountEventSubscriberChannel, + cancellation: AccountEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + public func makeAsyncIterator() -> Iterator { + .init(channel: channel, cancellation: cancellation) + } + + public func cancel() async { + cancellation.cancel() + } + + package func waitUntilNextSuspendsForTesting() async { + await channel.waitUntilNextSuspendsForTesting() + } + + public struct Iterator: AsyncIteratorProtocol { + private let channel: AccountEventSubscriberChannel + private let cancellation: AccountEventSubscriptionCancellation + + fileprivate init( + channel: AccountEventSubscriberChannel, + cancellation: AccountEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + public mutating func next() async throws -> CodexAccountEvent? { + try await channel.next(cancellation: cancellation) + } + } +} + +private final class AccountEventSubscriberChannel: Sendable { + private enum Terminal { + case open + case finished(CodexAppServerError?) + } + + private struct PendingEvent { + var sequence: UInt64 + var event: CodexAccountEvent + } + + private struct State { + var nextSequence: UInt64 = 0 + var accountChanged: PendingEvent? + var rateLimits: PendingEvent? + var waiter: CheckedContinuation< + Result, Never + >? + var suspensionObservers: [CheckedContinuation] = [] + var terminal = Terminal.open + } + + private let state = Mutex(State()) + + func yield(_ event: CodexAccountEvent) { + let waiter = state.withLock { state -> CheckedContinuation< + Result, Never + >? in + guard case .open = state.terminal else { + return nil + } + if let waiter = state.waiter { + state.waiter = nil + return waiter + } + switch event { + case .accountUpdated: + let sequence = state.accountChanged?.sequence ?? state.nextSequence + state.accountChanged = .init(sequence: sequence, event: event) + case .rateLimitsUpdated: + let sequence = state.rateLimits?.sequence ?? state.nextSequence + state.rateLimits = .init(sequence: sequence, event: event) + case .malformed, .unknown: + preconditionFailure("AccountEventHub received an event outside its owned kinds.") + } + state.nextSequence &+= 1 + return nil + } + waiter?.resume(returning: .success(event)) + } + + func next( + cancellation: AccountEventSubscriptionCancellation + ) async throws -> CodexAccountEvent? { + try await withTaskCancellationHandler { + let result = await withCheckedContinuation { continuation in + let registration = state.withLock { state -> ( + Result?, + [CheckedContinuation] + ) in + if let event = Self.takeNextPending(from: &state) { + return (.success(event), []) + } + switch state.terminal { + case .open: + precondition(state.waiter == nil, "CodexAccountEvents supports one iterator.") + state.waiter = continuation + let observers = state.suspensionObservers + state.suspensionObservers.removeAll() + return (nil, observers) + case .finished(let error): + if let error { + return (.failure(error), []) + } + return (.success(nil), []) + } + } + for observer in registration.1 { + observer.resume() + } + if let immediate = registration.0 { + continuation.resume(returning: immediate) + } + } + return try result.get() + } onCancel: { + cancellation.cancel() + } + } + + func waitUntilNextSuspendsForTesting() async { + await withCheckedContinuation { continuation in + let isAlreadySuspendedOrFinished = state.withLock { state in + if state.waiter != nil { + return true + } + guard case .open = state.terminal else { + return true + } + state.suspensionObservers.append(continuation) + return false + } + if isAlreadySuspendedOrFinished { + continuation.resume() + } + } + } + + func finish(throwing error: CodexAppServerError?) { + let completion = state.withLock { state -> ( + CheckedContinuation, Never>?, + [CheckedContinuation] + ) in + guard case .open = state.terminal else { + return (nil, []) + } + state.terminal = .finished(error) + state.accountChanged = nil + state.rateLimits = nil + let waiter = state.waiter + state.waiter = nil + let observers = state.suspensionObservers + state.suspensionObservers.removeAll() + return (waiter, observers) + } + for observer in completion.1 { + observer.resume() + } + if let error { + completion.0?.resume(returning: .failure(error)) + } else { + completion.0?.resume(returning: .success(nil)) + } + } + + private static func takeNextPending(from state: inout State) -> CodexAccountEvent? { + enum Kind { + case accountChanged + case rateLimits + } + let candidates: [(Kind, PendingEvent)] = [ + state.accountChanged.map { (.accountChanged, $0) }, + state.rateLimits.map { (.rateLimits, $0) }, + ].compactMap(\.self) + guard let (kind, pending) = candidates.min(by: { $0.1.sequence < $1.1.sequence }) else { + return nil + } + switch kind { + case .accountChanged: + state.accountChanged = nil + case .rateLimits: + state.rateLimits = nil + } + return pending.event + } +} + +private final class AccountEventSubscriptionCancellation: Sendable { + private struct State { + var isCancelled = false + } + + private let state = Mutex(State()) + private let id: UUID + private let registry: AccountEventSubscriptionRegistry + + init(id: UUID, registry: AccountEventSubscriptionRegistry) { + self.id = id + self.registry = registry + } + + func cancel() { + let shouldRemove = state.withLock { state in + guard state.isCancelled == false else { + return false + } + state.isCancelled = true + return true + } + if shouldRemove { + registry.remove(id) + } + } + + deinit { + cancel() + } +} + +private final class AccountEventSubscriptionRegistry: Sendable { + private struct State { + var channels: [UUID: AccountEventSubscriberChannel] = [:] + var terminalError: CodexAppServerError? + } + + private let state = Mutex(State()) + + var isFinished: Bool { + state.withLock { $0.terminalError != nil } + } + + var count: Int { + state.withLock { $0.channels.count } + } + + func makeEvents() -> CodexAccountEvents { + let id = UUID() + let channel = AccountEventSubscriberChannel() + let cancellation = AccountEventSubscriptionCancellation(id: id, registry: self) + let terminalError = state.withLock { state -> CodexAppServerError? in + guard let terminalError = state.terminalError else { + state.channels[id] = channel + return nil + } + return terminalError + } + if let terminalError { + channel.finish(throwing: terminalError) + } + return .init(channel: channel, cancellation: cancellation) + } + + func yield(_ event: CodexAccountEvent) { + let channels = state.withLock { Array($0.channels.values) } + for channel in channels { + channel.yield(event) + } + } + + func remove(_ id: UUID) { + let channel = state.withLock { $0.channels.removeValue(forKey: id) } + channel?.finish(throwing: nil) + } + + func finish(throwing error: CodexAppServerError) { + let channels = state.withLock { state -> [AccountEventSubscriberChannel] in + guard state.terminalError == nil else { + return [] + } + state.terminalError = error + let channels = Array(state.channels.values) + state.channels.removeAll() + return channels + } + for channel in channels { + channel.finish(throwing: error) + } + } +} + +extension AppServerAPI.Account.RateLimits.Snapshot { + package func merging( + _ sparseUpdate: AppServerAPI.Account.RateLimits.Snapshot + ) -> AppServerAPI.Account.RateLimits.Snapshot { + .init( + limitID: sparseUpdate.limitID ?? limitID, + primary: primary.merging(sparseUpdate.primary), + secondary: secondary.merging(sparseUpdate.secondary), + planType: sparseUpdate.planType ?? planType + ) + } + + fileprivate var normalizedLimitID: String { + let trimmed = limitID?.trimmingCharacters(in: .whitespacesAndNewlines) + return if let trimmed, trimmed.isEmpty == false { + trimmed + } else { + "codex" + } + } +} + +private extension Optional where Wrapped == AppServerAPI.Account.RateLimits.Window { + func merging( + _ sparseUpdate: AppServerAPI.Account.RateLimits.Window? + ) -> AppServerAPI.Account.RateLimits.Window? { + guard let sparseUpdate else { + return self + } + guard let current = self else { + return sparseUpdate + } + return .init( + usedPercent: sparseUpdate.usedPercent, + windowDurationMins: sparseUpdate.windowDurationMins + ?? current.windowDurationMins, + resetsAt: sparseUpdate.resetsAt ?? current.resetsAt + ) + } +} + +extension AppServerAPI.Account.RateLimits.Response { + package func merging( + _ sparseUpdate: AppServerAPI.Account.RateLimits.Snapshot + ) -> AppServerAPI.Account.RateLimits.Response { + let updateID = sparseUpdate.normalizedLimitID + var rateLimits = rateLimits + var rateLimitsByLimitID = rateLimitsByLimitID + + if var snapshots = rateLimitsByLimitID { + let existing = snapshots[updateID] + ?? snapshots.first(where: { key, value in + key == updateID || value.normalizedLimitID == updateID + })?.value + ?? .init(limitID: sparseUpdate.limitID) + snapshots[updateID] = existing.merging(sparseUpdate) + rateLimitsByLimitID = snapshots + } else if rateLimits.normalizedLimitID == updateID { + rateLimits = rateLimits.merging(sparseUpdate) + } else { + rateLimitsByLimitID = [ + rateLimits.normalizedLimitID: rateLimits, + updateID: sparseUpdate, + ] + } + + return .init( + rateLimits: rateLimits, + rateLimitsByLimitID: rateLimitsByLimitID + ) + } +} diff --git a/Sources/CodexAppServerKit/AppServerClient.swift b/Sources/CodexAppServerKit/AppServerClient.swift new file mode 100644 index 00000000..5d0bb1b5 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerClient.swift @@ -0,0 +1,676 @@ +import Foundation +import OSLog + +private let logger = Logger(subsystem: "CodexAppServerKit", category: "app-server-client") + +package actor AppServerClient { + private struct InitializationContract: Equatable { + var clientName: String + var clientVersion: String + } + + private enum InitializationState { + case idle + case inFlight( + contract: InitializationContract, + waiters: [CheckedContinuation] + ) + case complete(AppServerAPI.Initialize.Response) + } + + private static let appServerOverloadedErrorCode = -32001 + private static let overloadRetryDelays: [Duration] = [ + .milliseconds(100), + .milliseconds(250), + .milliseconds(500), + ] + + private let transport: any JSONRPC.Transport + package nonisolated let connectionEventHub: ConnectionEventHub + private let overloadRetryDelay: @Sendable (Int) -> Duration? + private let retrySleep: @Sendable (Duration) async throws -> Void + private let deadlines: CodexAppServer.Configuration.Deadlines + private let deadlineClock: CodexDeadlineClock + private let connectionCloseAction: ConnectionCloseAction + private let serializer = RequestSerializer() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private var nextRequestID = 1 + private var initializationState = InitializationState.idle + + package init( + transport: any JSONRPC.Transport, + deadlines: CodexAppServer.Configuration.Deadlines = .init(), + deadlineClock: CodexDeadlineClock = .continuous, + connectionCloseAction: ConnectionCloseAction, + overloadRetryDelay: @escaping @Sendable (Int) -> Duration? = AppServerClient + .defaultOverloadRetryDelay, + retrySleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + } + ) { + self.transport = transport + self.connectionEventHub = transport.connectionEventHub + self.deadlines = deadlines + self.deadlineClock = deadlineClock + self.connectionCloseAction = connectionCloseAction + self.overloadRetryDelay = overloadRetryDelay + self.retrySleep = retrySleep + } + + package func initialize( + clientName: String = "CodexAppServerKit", + clientVersion: String = "2" + ) async throws -> AppServerAPI.Initialize.Response { + let contract = InitializationContract( + clientName: clientName, + clientVersion: clientVersion + ) + switch initializationState { + case .complete(let response): + return response + case .inFlight(let existingContract, var waiters): + precondition( + existingContract == contract, + "Concurrent initialization must use the same client name and version." + ) + return try await withCheckedThrowingContinuation { continuation in + waiters.append(continuation) + initializationState = .inFlight( + contract: existingContract, + waiters: waiters + ) + } + case .idle: + initializationState = .inFlight(contract: contract, waiters: []) + } + + do { + let response = try await performInitialize( + clientName: clientName, + clientVersion: clientVersion + ) + finishInitialization(with: .success(response)) + return response + } catch { + finishInitialization(with: .failure(error)) + throw error + } + } + + package func sleepForInterruptRace(_ duration: Duration) async throws { + try await deadlineClock.sleep(duration) + } + + package func initializationWaiterCountForTesting() -> Int { + guard case .inFlight(_, let waiters) = initializationState else { + return 0 + } + return waiters.count + } + + private func finishInitialization( + with result: Result + ) { + guard case .inFlight(_, let waiters) = initializationState else { + preconditionFailure("Initialization completed without an in-flight owner.") + } + switch result { + case .success(let response): + initializationState = .complete(response) + case .failure: + initializationState = .idle + } + for waiter in waiters { + waiter.resume(with: result) + } + } + + private func performInitialize( + clientName: String, + clientVersion: String + ) async throws -> AppServerAPI.Initialize.Response { + logger.info( + "Initializing codex app-server connection as \(clientName, privacy: .public) \(clientVersion, privacy: .public)" + ) + let response: AppServerAPI.Initialize.Response = try await send( + AppServerAPI.Initialize.Request( + params: .init(clientName: clientName, clientVersion: clientVersion) + ), + purpose: .handshake, + deadline: deadlines.handshake ?? deadlines.request, + afterResponse: { [transport, encoder] _ in + let params = try encoder.encode(EmptyResponse()) + try await transport.notify(.init(method: "initialized", params: params)) + } + ) + logger.info("codex app-server connection initialized") + return response + } + + package func send( + _ request: Request, + reconcileResponse: @escaping @Sendable (Request.Response) async throws -> Void = { _ in }, + onWriteAccepted: @escaping @Sendable () -> Void = {}, + onResponseRejected: @escaping @Sendable () async throws -> Void = {}, + onResponseAccepted: @escaping @Sendable () -> Void = {}, + retriesOverloadResponses: Bool = true, + postWriteCallerCancellationPolicy: RequestOperationState + .PostWriteCallerCancellationPolicy = .performCleanup, + onPostWriteCancellation: @escaping @Sendable (Request.Response) async throws -> Void = { _ in } + ) async throws -> Request.Response { + try await send( + method: Request.method, + params: request.params, + responseType: Request.Response.self, + scope: request.scope, + purpose: .operation(Request.method), + deadline: deadlines.request, + reconcileResponse: reconcileResponse, + onWriteAccepted: onWriteAccepted, + onResponseRejected: onResponseRejected, + onResponseAccepted: onResponseAccepted, + retriesOverloadResponses: retriesOverloadResponses, + postWriteCallerCancellationPolicy: postWriteCallerCancellationPolicy, + onPostWriteCancellation: onPostWriteCancellation + ) + } + + private func send( + _ request: Request, + purpose: CodexRequestPurpose, + deadline: Duration?, + afterResponse: @escaping @Sendable (Request.Response) async throws -> Void + ) async throws -> Request.Response { + try await send( + method: Request.method, + params: request.params, + responseType: Request.Response.self, + scope: request.scope, + purpose: purpose, + deadline: deadline, + afterResponse: afterResponse + ) + } + + package func send( + method: String, + params: Params, + responseType: Response.Type, + scope: AppServerAPI.RequestScope? = nil, + purpose: CodexRequestPurpose? = nil, + deadline: Duration? = nil, + reconcileResponse: @escaping @Sendable (Response) async throws -> Void = { _ in }, + afterResponse: @escaping @Sendable (Response) async throws -> Void = { _ in }, + onWriteAccepted: @escaping @Sendable () -> Void = {}, + onResponseRejected: @escaping @Sendable () async throws -> Void = {}, + onResponseAccepted: @escaping @Sendable () -> Void = {}, + retriesOverloadResponses: Bool = true, + postWriteCallerCancellationPolicy: RequestOperationState + .PostWriteCallerCancellationPolicy = .performCleanup, + onPostWriteCancellation: @escaping @Sendable (Response) async throws -> Void = { _ in } + ) async throws -> Response { + try await serializer.run(scope: scope) { [encoder, self] laneToken in + let requestID = await self.allocateRequestID() + let requestPurpose = purpose ?? .operation(method) + let encodedParams: Data + do { + encodedParams = try encoder.encode(params) + } catch is CancellationError { + throw CancellationError() + } catch { + throw CodexAppServerError.request(.init( + requestID: requestID, + method: method, + purpose: requestPurpose, + kind: .encode(message: error.localizedDescription) + )) + } + let initialRequestID = requestID + let state = RequestOperationState() + let operation = { @Sendable [self] in + let response = try await performRequestWithRetries( + initialRequestID: initialRequestID, + method: method, + encodedParams: encodedParams, + responseType: responseType, + purpose: requestPurpose, + reconcileResponse: reconcileResponse, + afterResponse: afterResponse, + onWriteAccepted: onWriteAccepted, + onResponseRejected: onResponseRejected, + onResponseAccepted: onResponseAccepted, + retriesOverloadResponses: retriesOverloadResponses, + operationState: state + ) + state.markResponseBound() + switch state.resolveResponse( + postWriteCallerCancellationPolicy: postWriteCallerCancellationPolicy + ) { + case .returnResponse: + return response + case .performCleanup(let abandonment): + try await serializer.runCleanup(using: laneToken) { + try await onPostWriteCancellation(response) + } + state.markCleanupComplete() + throw abandonment + } + } + return try await withTaskCancellationHandler { + let operationTask = Task { + do { + guard let deadline else { + return try await operation() + } + do { + return try await self.runRequestWithDeadline( + deadline, + operationState: state, + operation: operation + ) + } catch is RequestDeadlineExpired { + throw CodexAppServerError.request(.init( + requestID: initialRequestID, + method: method, + purpose: requestPurpose, + kind: .deadlineExceeded(deadline) + )) + } + } catch RequestOperationAbandonment.callerCancellation { + throw CancellationError() + } catch RequestOperationAbandonment.deadline { + throw RequestDeadlineExpired() + } catch { + if state.preWriteCancellationShouldWin() { + throw CancellationError() + } + throw error + } + } + return try await operationTask.value + } onCancel: { + state.requestCancellation() + } + } + } + + private func performRequestWithRetries( + initialRequestID: Int, + method: String, + encodedParams: Data, + responseType: Response.Type, + purpose: CodexRequestPurpose, + reconcileResponse: @escaping @Sendable (Response) async throws -> Void, + afterResponse: @escaping @Sendable (Response) async throws -> Void, + onWriteAccepted: @escaping @Sendable () -> Void, + onResponseRejected: @escaping @Sendable () async throws -> Void, + onResponseAccepted: @escaping @Sendable () -> Void, + retriesOverloadResponses: Bool, + operationState: RequestOperationState + ) async throws -> Response { + var requestID = initialRequestID + var retryAttempt = 0 + while true { + let attemptRequestID = requestID + logger.debug( + "JSON-RPC request \(attemptRequestID, privacy: .public) -> \(method, privacy: .public)" + ) + do { + let rawResponse: Data + do { + rawResponse = try await transport.send( + .init( + id: attemptRequestID, + method: method, + params: encodedParams + ), + acceptWrite: { + try operationState.acceptWrite() + onWriteAccepted() + } + ) + } catch let abandonment as RequestOperationAbandonment { + throw abandonment + } catch is CancellationError { + throw CancellationError() + } catch let error as JSONRPC.Error { + throw error + } catch let error as JSONRPC.OutboundWriteFailure { + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .write(error.failure) + )) + } catch { + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .transport(Self.transportFailure(from: error)) + )) + } + let response: Response + do { + response = try decoder.decode(responseType, from: rawResponse) + } catch is CancellationError { + throw CancellationError() + } catch { + try await reconcileRejectedResponse(using: onResponseRejected) + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .invalidResponse( + expectedType: String(reflecting: responseType), + message: error.localizedDescription, + rawData: rawResponse + ) + )) + } + do { + try await reconcileResponse(response) + } catch is CancellationError { + throw CancellationError() + } catch let error as CodexAppServerError { + if case .connectionTerminated = error { + throw error + } + let failure = CodexTransportFailure.protocolViolation( + message: "Response reconciliation failed: \(error.localizedDescription)", + rawData: rawResponse + ) + await connectionCloseAction.failConnection(with: failure) + throw CodexAppServerError.connectionTerminated(.transportFailure(failure)) + } catch { + let failure = (error as? CodexTransportFailure) ?? .protocolViolation( + message: "Response reconciliation failed: \(error.localizedDescription)", + rawData: rawResponse + ) + await connectionCloseAction.failConnection(with: failure) + throw CodexAppServerError.connectionTerminated(.transportFailure(failure)) + } + do { + try await afterResponse(response) + } catch is CancellationError { + throw CancellationError() + } catch { + try await reconcileRejectedResponse(using: onResponseRejected) + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .write(Self.transportFailure(from: error)) + )) + } + onResponseAccepted() + logger.debug( + "JSON-RPC response \(attemptRequestID, privacy: .public) <- \(method, privacy: .public)" + ) + return response + } catch is CancellationError { + throw CancellationError() + } catch let abandonment as RequestOperationAbandonment { + throw abandonment + } catch let error as JSONRPC.Error { + if case .responseError = error { + try await reconcileRejectedResponse(using: onResponseRejected) + } + if case .responseError(let serverError) = error, + serverError.code == Self.appServerOverloadedErrorCode, + retriesOverloadResponses { + guard let delay = overloadRetryDelay(retryAttempt) else { + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .overloadRetryExhausted( + last: serverError, + attempts: retryAttempt + 1 + ) + )) + } + retryAttempt += 1 + connectionEventHub.yield(.retrying(.init( + requestID: attemptRequestID, + method: method, + attempt: retryAttempt, + delay: delay, + serverError: serverError + ))) + logger.warning( + "JSON-RPC request \(attemptRequestID, privacy: .public) overloaded for \(method, privacy: .public); retrying in \(String(describing: delay), privacy: .public)" + ) + try await waitForRetryDelay( + delay, + operationState: operationState + ) + requestID = allocateRequestID() + continue + } + switch error { + case .responseError(let serverError): + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .server(serverError) + )) + case .closed: + throw CodexAppServerError.connectionTerminated(.transportFailure(.closed)) + case .invalidMessage(let message): + throw CodexAppServerError.connectionTerminated(.transportFailure( + .protocolViolation(message: message, rawData: nil) + )) + } + } catch let error as CodexAppServerError { + throw error + } catch { + throw CodexAppServerError.request(.init( + requestID: attemptRequestID, + method: method, + purpose: purpose, + kind: .transport(Self.transportFailure(from: error)) + )) + } + } + } + + private func reconcileRejectedResponse( + using action: @escaping @Sendable () async throws -> Void + ) async throws { + do { + try await action() + } catch let error as CodexAppServerError { + if case .connectionTerminated = error { + throw error + } + let failure = CodexTransportFailure.protocolViolation( + message: "Response rejection reconciliation failed: \(error.localizedDescription)", + rawData: nil + ) + await connectionCloseAction.failConnection(with: failure) + throw CodexAppServerError.connectionTerminated(.transportFailure(failure)) + } catch { + let failure = CodexTransportFailure.protocolViolation( + message: "Response rejection reconciliation failed: \(error.localizedDescription)", + rawData: nil + ) + await connectionCloseAction.failConnection(with: failure) + throw CodexAppServerError.connectionTerminated(.transportFailure(failure)) + } + } + + package func requestLaneCountForTesting() async -> Int { + await serializer.laneCountForTesting() + } + + package func queuedRequestCountForTesting( + scope: AppServerAPI.RequestScope + ) async -> Int { + await serializer.queuedWaiterCountForTesting(scope: scope) + } + + package func waitForQueuedRequestCountForTesting( + scope: AppServerAPI.RequestScope, + atLeast minimumCount: Int + ) async throws { + try await serializer.waitForQueuedWaiterCountForTesting( + scope: scope, + atLeast: minimumCount + ) + } + package func runTurnWithDeadline( + turnID: CodexTurnID, + duration: Duration, + operation: @escaping @Sendable () async throws -> Output + ) async throws -> Output { + do { + return try await runWithDeadline(duration, operation: operation) + } catch is RequestDeadlineExpired { + throw CodexAppServerError.turnDeadlineExceeded( + turnID: turnID, + duration: duration + ) + } + } + + private func allocateRequestID() -> Int { + defer { nextRequestID += 1 } + return nextRequestID + } + + private func runWithDeadline( + _ deadline: Duration, + operation: @escaping @Sendable () async throws -> Output + ) async throws -> Output { + try await withThrowingTaskGroup(of: DeadlineRace.self) { group in + group.addTask { .value(try await operation()) } + group.addTask { [deadlineClock] in + try await deadlineClock.sleep(deadline) + return .expired + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + preconditionFailure("Deadline race must have a winner.") + } + switch result { + case .value(let value): + return value + case .expired: + throw RequestDeadlineExpired() + } + } + } + + private func runRequestWithDeadline( + _ deadline: Duration, + operationState: RequestOperationState, + operation: @escaping @Sendable () async throws -> Output + ) async throws -> Output { + try await withThrowingTaskGroup(of: DeadlineRace.self) { group in + group.addTask { .value(try await operation()) } + group.addTask { [deadlineClock] in + try await deadlineClock.sleep(deadline) + return .expired + } + var deadlineWon = false + while true { + let result: DeadlineRace + do { + guard let next = try await group.next() else { + preconditionFailure("Deadline race must have a winner.") + } + result = next + } catch { + if deadlineWon { + throw RequestDeadlineExpired() + } + throw error + } + switch result { + case .value(let value): + group.cancelAll() + return value + case .expired: + switch operationState.requestDeadline() { + case .ignored: + continue + case .awaitPreWriteExit: + deadlineWon = true + case .closeConnection: + deadlineWon = true + await connectionCloseAction.closeConnection() + } + } + } + } + } + + private func waitForRetryDelay( + _ delay: Duration, + operationState: RequestOperationState + ) async throws { + try operationState.beginRetryWait() + let retrySleep = self.retrySleep + try await withThrowingTaskGroup(of: RetryDelayRace.self) { group in + group.addTask { + try await retrySleep(delay) + return .delayElapsed + } + group.addTask { + if let abandonment = await operationState.waitForAbandonment() { + return .abandoned(abandonment) + } + return .waiterCancelled + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + preconditionFailure("A retry delay race must have a winner.") + } + switch result { + case .delayElapsed: + break + case .abandoned(let abandonment): + throw abandonment + case .waiterCancelled: + try Task.checkCancellation() + preconditionFailure("An active retry abandonment waiter cannot end without a signal.") + } + } + try operationState.finishRetryWait() + } + + private nonisolated static func transportFailure(from error: Error) -> CodexTransportFailure { + if let failure = error as? CodexTransportFailure { + return failure + } + if let posixError = error as? POSIXError { + return .io(errno: posixError.code.rawValue, message: posixError.localizedDescription) + } + return .io(errno: nil, message: error.localizedDescription) + } + + private nonisolated static func defaultOverloadRetryDelay(for retryAttempt: Int) -> Duration? { + guard retryAttempt < overloadRetryDelays.count else { + return nil + } + let base = overloadRetryDelays[retryAttempt] + let jitter = Duration.milliseconds(Int.random(in: 0...50)) + return base + jitter + } +} + +private enum DeadlineRace: Sendable { + case value(Value) + case expired +} + +private enum RetryDelayRace: Sendable { + case delayElapsed + case abandoned(RequestOperationAbandonment) + case waiterCancelled +} + +private struct RequestDeadlineExpired: Error, Sendable {} diff --git a/Sources/CodexAppServerKit/AppServerConnection.swift b/Sources/CodexAppServerKit/AppServerConnection.swift new file mode 100644 index 00000000..5a91fd5d --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerConnection.swift @@ -0,0 +1,235 @@ +import Foundation +import OSLog + +private let connectionLogger = Logger( + subsystem: "CodexAppServerKit", + category: "app-server-connection" +) + +package enum ConnectionExitSignal: Equatable, Sendable { + case transport(CodexTransportFailure) + case processExited(status: Int32?, observedBeforeTermination: Bool) + + package var terminationCandidate: ConnectionTerminationArbiter.Candidate { + switch self { + case .transport(let failure): + .init(.transportFailure(failure)) + case .processExited(let status, let observedBeforeTermination): + .init( + .processExited(status: status), + observedBeforeTermination: observedBeforeTermination + ) + } + } +} + +package actor AppServerConnection { + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + package nonisolated let connectionEventHub: ConnectionEventHub + package nonisolated let turnReplayStore: TurnReplayStore + + private let transport: any JSONRPC.Transport + private let serverRequestRegistry: ServerRequestRegistry + private let notificationDecoder = AppServerNotificationDecoder() + + package init( + transport: any JSONRPC.Transport, + client: AppServerClient, + router: CodexAppServerNotificationRouter, + turnReplayStore: TurnReplayStore, + serverRequestHandler: @escaping CodexAppServerRequestHandler, + serverRequestDiagnosticHandler: @escaping ServerRequestRegistry.DiagnosticHandler = { + diagnostic in + connectionLogger.error( + "App-server request registry: \(String(describing: diagnostic), privacy: .public)" + ) + } + ) { + precondition( + client.connectionEventHub === transport.connectionEventHub, + "A connection must preserve its transport's event hub identity." + ) + precondition( + router.turnReplayStore === turnReplayStore, + "A connection and its router must share one turn replay store identity." + ) + self.transport = transport + self.client = client + self.router = router + self.turnReplayStore = turnReplayStore + self.connectionEventHub = client.connectionEventHub + self.serverRequestRegistry = ServerRequestRegistry( + connectionEventHub: client.connectionEventHub, + handler: serverRequestHandler, + responder: { [transport] id, response in + try await transport.respond(to: id, with: response) + }, + diagnosticHandler: serverRequestDiagnosticHandler + ) + } + + package func runInboundEvents( + onExit: @escaping @Sendable (ConnectionExitSignal) async -> Void + ) async { + do { + while let event = try await transport.nextInboundEvent() { + try await apply(event) + } + await onExit(.transport(.closed)) + } catch is CancellationError { + return + } catch let failure as CodexTransportFailure { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.routingFailure( + message: failure.localizedDescription + ) + )) + await onExit(.transport(failure)) + await drainResponsesOnly() + } catch let error as CodexAppServerError { + connectionLogger.error( + "App-server domain routing failed: \(error.localizedDescription, privacy: .public)" + ) + let method: String? = if case .malformedNotification(let malformed) = error { + malformed.method + } else { + nil + } + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.routingFailure( + message: error.localizedDescription, + method: method + ) + )) + await router.finishLogin(throwing: error) + await onExit(.transport(Self.transportFailure(for: error))) + await drainResponsesOnly() + } catch { + let failure = CodexTransportFailure.protocolViolation( + message: error.localizedDescription, + rawData: nil + ) + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.routingFailure( + message: error.localizedDescription + ) + )) + await onExit(.transport(failure)) + await drainResponsesOnly() + } + } + + package func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { + await transport.waitForProcessExit() + } + + package func signalCloseIfOwned(by context: ServerRequestTaskContext.Value) async -> Bool { + await serverRequestRegistry.signalCloseIfOwned(by: context) + } + + package func beginClose() async -> JSONRPC.ProcessExitObservation? { + await serverRequestRegistry.beginClosing() + return await transport.beginClose() + } + + package func finishPendingResponsesAfterInboundDrain( + _ failure: CodexTransportFailure + ) async { + await transport.finishPendingResponsesAfterInboundDrain(failure) + } + + package func cancelServerRequestsAndWait() async { + await serverRequestRegistry.cancelAllAndWait() + } + + package func finishDomains(with termination: CodexConnectionTermination) async { + await router.finishAll(with: termination) + connectionEventHub.finish(with: termination) + } + + package func waitUntilTransportClosed() async { + await transport.waitUntilClosed() + } + + package func reapProcess() async { + await transport.reapProcess() + } + + package func serverRequestChildCount() async -> Int { + await serverRequestRegistry.childCount() + } + + package func waitUntilServerRequestsIdle() async { + await serverRequestRegistry.waitUntilIdle() + } + + package func waitForServerRequestReceiveCount(atLeast minimumCount: Int) async { + await serverRequestRegistry.waitForReceivedEventCount(atLeast: minimumCount) + } + + private func apply(_ event: JSONRPC.InboundEvent) async throws { + switch event { + case .notification(let notification): + let decoded = try notificationDecoder.decode(notification) + if case .serverRequestResolved(let requestID) = decoded.payload { + await serverRequestRegistry.resolve(requestID) + return + } + if case .connectionDiagnostic(let event) = decoded.payload { + connectionEventHub.yield(event) + return + } + try await router.route(decoded) + case .serverRequest(let id, let method, let params): + await serverRequestRegistry.receive(id: id, method: method, params: params) + } + } + + private func drainResponsesOnly() async { + while true { + do { + guard let event = try await transport.nextInboundEvent() else { + return + } + switch event { + case .notification(let notification): + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.droppedNotification( + method: notification.method + ) + )) + connectionLogger.error( + "Dropping \(notification.method, privacy: .public) while draining responses" + ) + case .serverRequest(let id, let method, _): + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.droppedServerRequest( + id: id, + method: method + ) + )) + connectionLogger.error( + "Dropping server request \(method, privacy: .public) while draining responses" + ) + } + } catch is CancellationError { + return + } catch { + continue + } + } + } + + private nonisolated static func transportFailure( + for error: CodexAppServerError + ) -> CodexTransportFailure { + if case .malformedNotification(let malformed) = error { + return .protocolViolation( + message: malformed.localizedDescription, + rawData: malformed.rawData + ) + } + return .protocolViolation(message: error.localizedDescription, rawData: nil) + } +} diff --git a/Sources/CodexAppServerKit/AppServerNotificationDecoder.swift b/Sources/CodexAppServerKit/AppServerNotificationDecoder.swift new file mode 100644 index 00000000..f012d213 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerNotificationDecoder.swift @@ -0,0 +1,1559 @@ +import Foundation + +package struct AppServerNotificationDecoder { + package enum Disposition: Equatable, Sendable { + case route + case diagnostic + case explicitIgnore + } + + package enum Method: String, CaseIterable, Equatable, Sendable { + case error + case threadStarted = "thread/started" + case threadStatusChanged = "thread/status/changed" + case threadArchived = "thread/archived" + case threadDeleted = "thread/deleted" + case threadUnarchived = "thread/unarchived" + case threadClosed = "thread/closed" + case skillsChanged = "skills/changed" + case threadNameUpdated = "thread/name/updated" + case threadGoalUpdated = "thread/goal/updated" + case threadGoalCleared = "thread/goal/cleared" + case threadSettingsUpdated = "thread/settings/updated" + case threadTokenUsageUpdated = "thread/tokenUsage/updated" + case turnStarted = "turn/started" + case hookStarted = "hook/started" + case turnCompleted = "turn/completed" + case hookCompleted = "hook/completed" + case turnDiffUpdated = "turn/diff/updated" + case turnPlanUpdated = "turn/plan/updated" + case itemStarted = "item/started" + case itemAutoApprovalReviewStarted = "item/autoApprovalReview/started" + case itemAutoApprovalReviewCompleted = "item/autoApprovalReview/completed" + case itemCompleted = "item/completed" + case rawResponseItemCompleted = "rawResponseItem/completed" + case itemAgentMessageDelta = "item/agentMessage/delta" + case itemPlanDelta = "item/plan/delta" + case commandExecOutputDelta = "command/exec/outputDelta" + case processOutputDelta = "process/outputDelta" + case processExited = "process/exited" + case itemCommandExecutionOutputDelta = "item/commandExecution/outputDelta" + case itemCommandExecutionTerminalInteraction = + "item/commandExecution/terminalInteraction" + case itemFileChangeOutputDelta = "item/fileChange/outputDelta" + case itemFileChangePatchUpdated = "item/fileChange/patchUpdated" + case serverRequestResolved = "serverRequest/resolved" + case itemMCPToolCallProgress = "item/mcpToolCall/progress" + case mcpServerOAuthLoginCompleted = "mcpServer/oauthLogin/completed" + case mcpServerStartupStatusUpdated = "mcpServer/startupStatus/updated" + case accountUpdated = "account/updated" + case accountRateLimitsUpdated = "account/rateLimits/updated" + case appListUpdated = "app/list/updated" + case remoteControlStatusChanged = "remoteControl/status/changed" + case externalAgentConfigImportProgress = "externalAgentConfig/import/progress" + case externalAgentConfigImportCompleted = "externalAgentConfig/import/completed" + case fsChanged = "fs/changed" + case itemReasoningSummaryTextDelta = "item/reasoning/summaryTextDelta" + case itemReasoningSummaryPartAdded = "item/reasoning/summaryPartAdded" + case itemReasoningTextDelta = "item/reasoning/textDelta" + case threadCompacted = "thread/compacted" + case modelRerouted = "model/rerouted" + case modelVerification = "model/verification" + case turnModerationMetadata = "turn/moderationMetadata" + case modelSafetyBufferingUpdated = "model/safetyBuffering/updated" + case warning + case guardianWarning + case deprecationNotice + case configWarning + case fuzzyFileSearchSessionUpdated = "fuzzyFileSearch/sessionUpdated" + case fuzzyFileSearchSessionCompleted = "fuzzyFileSearch/sessionCompleted" + case threadRealtimeStarted = "thread/realtime/started" + case threadRealtimeItemAdded = "thread/realtime/itemAdded" + case threadRealtimeTranscriptDelta = "thread/realtime/transcript/delta" + case threadRealtimeTranscriptDone = "thread/realtime/transcript/done" + case threadRealtimeOutputAudioDelta = "thread/realtime/outputAudio/delta" + case threadRealtimeSDP = "thread/realtime/sdp" + case threadRealtimeError = "thread/realtime/error" + case threadRealtimeClosed = "thread/realtime/closed" + case windowsWorldWritableWarning = "windows/worldWritableWarning" + case windowsSandboxSetupCompleted = "windowsSandbox/setupCompleted" + case accountLoginCompleted = "account/login/completed" + + package var disposition: Disposition { + switch self { + case .error, + .threadStarted, + .threadStatusChanged, + .threadArchived, + .threadDeleted, + .threadUnarchived, + .threadClosed, + .threadNameUpdated, + .threadTokenUsageUpdated, + .turnStarted, + .turnCompleted, + .turnDiffUpdated, + .turnPlanUpdated, + .itemStarted, + .itemCompleted, + .itemAgentMessageDelta, + .itemPlanDelta, + .itemCommandExecutionOutputDelta, + .itemFileChangePatchUpdated, + .serverRequestResolved, + .itemMCPToolCallProgress, + .accountUpdated, + .accountRateLimitsUpdated, + .accountLoginCompleted, + .itemReasoningSummaryTextDelta, + .itemReasoningSummaryPartAdded, + .itemReasoningTextDelta: + .route + + case .warning, + .guardianWarning, + .deprecationNotice, + .configWarning, + .modelRerouted, + .modelVerification, + .turnModerationMetadata, + .modelSafetyBufferingUpdated, + .windowsWorldWritableWarning, + .windowsSandboxSetupCompleted: + .diagnostic + + case .skillsChanged, + .threadGoalUpdated, + .threadGoalCleared, + .threadSettingsUpdated, + .hookStarted, + .hookCompleted, + .itemAutoApprovalReviewStarted, + .itemAutoApprovalReviewCompleted, + .rawResponseItemCompleted, + .commandExecOutputDelta, + .processOutputDelta, + .processExited, + .itemCommandExecutionTerminalInteraction, + .itemFileChangeOutputDelta, + .mcpServerOAuthLoginCompleted, + .mcpServerStartupStatusUpdated, + .appListUpdated, + .remoteControlStatusChanged, + .externalAgentConfigImportProgress, + .externalAgentConfigImportCompleted, + .fsChanged, + .threadCompacted, + .fuzzyFileSearchSessionUpdated, + .fuzzyFileSearchSessionCompleted, + .threadRealtimeStarted, + .threadRealtimeItemAdded, + .threadRealtimeTranscriptDelta, + .threadRealtimeTranscriptDone, + .threadRealtimeOutputAudioDelta, + .threadRealtimeSDP, + .threadRealtimeError, + .threadRealtimeClosed: + .explicitIgnore + } + } + } + + package struct Context: Equatable, Sendable { + package var threadID: CodexThreadID? + package var turnID: CodexTurnID? + + package init(threadID: CodexThreadID? = nil, turnID: CodexTurnID? = nil) { + self.threadID = threadID + self.turnID = turnID + } + } + + package enum AccountMutation: Equatable, Sendable { + case updated(AccountUpdate) + case rateLimitsUpdated(AppServerAPI.Account.RateLimits.Snapshot) + case loginCompleted(CodexLoginCompletion) + } + + package struct AccountUpdate: Equatable, Sendable { + package enum AuthMode: String, Equatable, Sendable { + case apiKey = "apikey" + case chatGPT = "chatgpt" + case chatGPTAuthTokens + case headers + case agentIdentity + case personalAccessToken + case bedrockAPIKey = "bedrockApiKey" + } + + package enum PlanType: String, Equatable, Sendable { + case free + case go + case plus + case pro + case prolite + case team + case selfServeBusinessUsageBased = "self_serve_business_usage_based" + case business + case enterpriseCBPUsageBased = "enterprise_cbp_usage_based" + case enterprise + case edu + case unknown + } + + package var authMode: AuthMode? + package var planType: PlanType? + } + + package enum Payload: Equatable, Sendable { + case turnCompleted(AppServerAPI.Turn.Payload) + case item(CodexItemReducer.Mutation) + case turnStarted(CodexTurnID) + case threadStatus(CodexThreadStatus) + case tokenUsage(CodexTokenUsage) + case threadClosed + case serverRequestResolved(CodexServerRequestID) + case account(AccountMutation) + case connectionDiagnostic(CodexConnectionEvent) + case raw + case ignored + } + + package struct DecodedNotification: Equatable, Sendable { + package var method: Method? + package var methodName: String + package var disposition: Disposition + package var context: Context + package var payload: Payload + package var rawData: Data + } + + private enum ItemLifecycle { + case started(Date) + case completed(Date) + } + + private let decoder = JSONDecoder() + + package init() {} + + package func decode(_ notification: JSONRPC.Notification) throws -> DecodedNotification { + guard let method = Method(rawValue: notification.method) else { + let context = Self.bestEffortContext(from: notification.params) + return .init( + method: nil, + methodName: notification.method, + disposition: .diagnostic, + context: context, + payload: .connectionDiagnostic(.unknown(.init( + method: notification.method, + params: notification.params, + threadID: context.threadID, + turnID: context.turnID + ))), + rawData: notification.params + ) + } + + do { + let object = try PayloadObject(data: notification.params, decoder: decoder) + let context = try validate(method, object: object) + let payload = try payload(method, object: object, data: notification.params) + return .init( + method: method, + methodName: notification.method, + disposition: method.disposition, + context: context, + payload: payload, + rawData: notification.params + ) + } catch let error as CodexAppServerError { + throw error + } catch { + throw CodexAppServerError.malformedNotification(.init( + method: notification.method, + message: error.localizedDescription, + rawData: notification.params + )) + } + } + + private func payload( + _ method: Method, + object: PayloadObject, + data: Data + ) throws -> Payload { + switch method { + case .error: + return .item(.turnDiagnostic(try decodeTurnDiagnostic(from: object))) + case .turnCompleted: + return .turnCompleted(try decodeTurn(from: object, data: data, lifecycle: .completed)) + case .turnStarted: + let turn = try decodeTurn(from: object, data: data, lifecycle: .started) + return .turnStarted(.init(rawValue: turn.id)) + case .itemStarted: + return .item(try decodeItem(from: object, lifecycle: .started( + Self.date(millisecondsSince1970: try object.requireInt64("startedAtMs")) + ))) + case .itemCompleted: + return .item(try decodeItem(from: object, lifecycle: .completed( + Self.date(millisecondsSince1970: try object.requireInt64("completedAtMs")) + ))) + case .itemAgentMessageDelta: + return .item(.agentMessageDelta( + itemID: try object.requireNonWhitespaceString("itemId"), + delta: try object.requireString("delta") + )) + case .itemPlanDelta: + return .item(.planDelta( + itemID: try object.requireNonEmptyString("itemId"), + delta: try object.requireString("delta") + )) + case .itemReasoningSummaryPartAdded: + return .item(.reasoningSummaryPartAdded( + itemID: try object.requireNonEmptyString("itemId"), + index: try object.requireInt("summaryIndex") + )) + case .itemReasoningSummaryTextDelta: + return .item(.reasoningSummaryDelta( + itemID: try object.requireNonEmptyString("itemId"), + index: try object.requireInt("summaryIndex"), + delta: try object.requireString("delta") + )) + case .itemReasoningTextDelta: + return .item(.reasoningTextDelta( + itemID: try object.requireNonEmptyString("itemId"), + index: try object.requireInt("contentIndex"), + delta: try object.requireString("delta") + )) + case .itemCommandExecutionOutputDelta: + return .item(.commandOutputDelta( + itemID: try object.requireNonEmptyString("itemId"), + delta: try object.requireString("delta") + )) + case .itemFileChangePatchUpdated: + let changes = try object.requireArray("changes") + try validateFileChanges(changes) + return .item(.filePatchSnapshot( + itemID: try object.requireNonEmptyString("itemId"), + output: try changes.map { + try PayloadObject.object($0, path: "changes[]").requireString("diff") + }.joined(separator: "\n") + )) + case .itemMCPToolCallProgress: + return .item(.mcpProgress( + itemID: try object.requireNonEmptyString("itemId"), + message: try object.requireString("message") + )) + case .threadStatusChanged: + return .threadStatus(try decodeThreadStatus(from: object.requireObject("status"))) + case .threadTokenUsageUpdated: + return .tokenUsage(try decodeTokenUsage(from: object.requireObject("tokenUsage"))) + case .threadClosed: + return .threadClosed + case .serverRequestResolved: + return .serverRequestResolved(try object.requireRequestID("requestId")) + case .accountUpdated: + return .account(.updated(try decodeAccountUpdate(from: object))) + case .accountRateLimitsUpdated: + return .account(.rateLimitsUpdated(try decodeRateLimitSnapshot( + from: object.requireObject("rateLimits") + ))) + case .accountLoginCompleted: + return .account(.loginCompleted(.init( + loginID: try object.optionalString("loginId").map { + CodexLoginHandle.ID(rawValue: $0) + }, + success: try object.requireBool("success"), + error: try object.optionalString("error") + ))) + case .warning, .guardianWarning: + return .connectionDiagnostic(.warning(.init( + message: try object.requireString("message"), + method: method.rawValue + ))) + case .deprecationNotice: + return .connectionDiagnostic(.deprecation(.init( + summary: try object.requireString("summary"), + details: try object.optionalString("details") + ))) + case .configWarning: + return .connectionDiagnostic(.warning(.init( + message: try object.requireString("summary"), + method: method.rawValue, + details: try object.optionalString("details") + ))) + case .modelRerouted, + .modelVerification, + .turnModerationMetadata, + .modelSafetyBufferingUpdated, + .windowsWorldWritableWarning, + .windowsSandboxSetupCompleted: + return .connectionDiagnostic(.warning(.init( + message: method.rawValue, + method: method.rawValue + ))) + case .threadStarted, + .threadArchived, + .threadDeleted, + .threadUnarchived, + .threadNameUpdated, + .turnDiffUpdated, + .turnPlanUpdated: + return .raw + case .skillsChanged, + .threadGoalUpdated, + .threadGoalCleared, + .threadSettingsUpdated, + .hookStarted, + .hookCompleted, + .itemAutoApprovalReviewStarted, + .itemAutoApprovalReviewCompleted, + .rawResponseItemCompleted, + .commandExecOutputDelta, + .processOutputDelta, + .processExited, + .itemCommandExecutionTerminalInteraction, + .itemFileChangeOutputDelta, + .mcpServerOAuthLoginCompleted, + .mcpServerStartupStatusUpdated, + .appListUpdated, + .remoteControlStatusChanged, + .externalAgentConfigImportProgress, + .externalAgentConfigImportCompleted, + .fsChanged, + .threadCompacted, + .fuzzyFileSearchSessionUpdated, + .fuzzyFileSearchSessionCompleted, + .threadRealtimeStarted, + .threadRealtimeItemAdded, + .threadRealtimeTranscriptDelta, + .threadRealtimeTranscriptDone, + .threadRealtimeOutputAudioDelta, + .threadRealtimeSDP, + .threadRealtimeError, + .threadRealtimeClosed: + return .ignored + } + } + + private func validate(_ method: Method, object: PayloadObject) throws -> Context { + switch method { + case .error: + _ = try object.requireObject("error") + _ = try object.requireBool("willRetry") + return try object.threadTurnContext() + case .threadStarted: + let thread = try object.requireObject("thread") + try validateThread(thread) + return .init(threadID: try thread.requiredThreadID(key: "id")) + case .threadStatusChanged: + _ = try decodeThreadStatus(from: object.requireObject("status")) + return .init(threadID: try object.requiredThreadID()) + case .threadArchived, .threadDeleted, .threadUnarchived, .threadClosed: + return .init(threadID: try object.requiredThreadID()) + case .threadNameUpdated: + _ = try object.optionalString("threadName") + return .init(threadID: try object.requiredThreadID()) + case .threadTokenUsageUpdated: + _ = try decodeTokenUsage(from: object.requireObject("tokenUsage")) + return try object.threadTurnContext() + case .turnStarted: + let turn = try object.requireObject("turn") + try validateTurn(turn, lifecycle: .started) + return .init( + threadID: try object.requiredThreadID(), + turnID: try turn.requiredTurnID(key: "id") + ) + case .turnCompleted: + let turn = try object.requireObject("turn") + try validateTurn(turn, lifecycle: .completed) + return .init( + threadID: try object.requiredThreadID(), + turnID: try turn.requiredTurnID(key: "id") + ) + case .turnDiffUpdated: + _ = try object.requireString("diff") + return try object.threadTurnContext() + case .turnPlanUpdated: + try validateTurnPlan(object.requireArray("plan")) + _ = try object.optionalString("explanation") + return try object.threadTurnContext() + case .itemStarted: + _ = try object.requireInt64("startedAtMs") + try validateThreadItem(object.requireObject("item"), lifecycle: .started) + return try object.threadTurnContext() + case .itemCompleted: + _ = try object.requireInt64("completedAtMs") + try validateThreadItem(object.requireObject("item"), lifecycle: .completed) + return try object.threadTurnContext() + case .itemAgentMessageDelta, + .itemPlanDelta, + .itemCommandExecutionOutputDelta, + .itemFileChangeOutputDelta: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireString("delta") + return try object.threadTurnContext() + case .itemReasoningSummaryTextDelta: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireInt("summaryIndex") + _ = try object.requireString("delta") + return try object.threadTurnContext() + case .itemReasoningSummaryPartAdded: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireInt("summaryIndex") + return try object.threadTurnContext() + case .itemReasoningTextDelta: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireInt("contentIndex") + _ = try object.requireString("delta") + return try object.threadTurnContext() + case .itemFileChangePatchUpdated: + _ = try object.requireNonEmptyString("itemId") + try validateFileChanges(object.requireArray("changes")) + return try object.threadTurnContext() + case .itemMCPToolCallProgress: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireString("message") + return try object.threadTurnContext() + case .serverRequestResolved: + _ = try object.requireRequestID("requestId") + return .init(threadID: try object.requiredThreadID()) + case .accountUpdated: + _ = try decodeAccountUpdate(from: object) + return .init() + case .accountRateLimitsUpdated: + _ = try decodeRateLimitSnapshot(from: object.requireObject("rateLimits")) + return .init() + case .accountLoginCompleted: + _ = try object.optionalString("loginId") + _ = try object.requireBool("success") + _ = try object.optionalString("error") + return .init() + case .warning: + _ = try object.requireString("message") + _ = try object.optionalString("threadId") + return Self.context(from: object) + case .guardianWarning: + _ = try object.requireString("message") + return .init(threadID: try object.requiredThreadID()) + case .deprecationNotice: + _ = try object.requireString("summary") + _ = try object.optionalString("details") + return Self.context(from: object) + case .configWarning: + _ = try object.requireString("summary") + _ = try object.optionalString("details") + _ = try object.optionalString("path") + if let range = try object.optionalObject("range") { + for endpoint in ["start", "end"] { + let position = try range.requireObject(endpoint) + _ = try position.requireInt("line") + _ = try position.requireInt("column") + } + } + return Self.context(from: object) + case .modelRerouted: + for key in ["fromModel", "reason", "toModel"] { + _ = try object.requireString(key) + } + return try object.threadTurnContext() + case .modelVerification: + _ = try object.requireArray("verifications") + return try object.threadTurnContext() + case .turnModerationMetadata: + _ = try object.requireValue("metadata") + return try object.threadTurnContext() + case .modelSafetyBufferingUpdated: + _ = try object.requireString("model") + _ = try object.requireArray("reasons") + _ = try object.requireBool("showBufferingUi") + _ = try object.requireArray("useCases") + return try object.threadTurnContext() + case .windowsWorldWritableWarning: + _ = try object.requireInt("extraCount") + _ = try object.requireBool("failedScan") + _ = try object.requireStringArray("samplePaths") + return .init() + case .windowsSandboxSetupCompleted: + _ = try object.requireEnum( + "mode", + allowed: ["elevated", "unelevated"] + ) + _ = try object.requireBool("success") + _ = try object.optionalString("error") + return .init() + case .skillsChanged: + return .init() + case .threadGoalUpdated: + try validateThreadGoal(object.requireObject("goal")) + _ = try object.optionalString("turnId") + return .init(threadID: try object.requiredThreadID()) + case .threadGoalCleared: + return .init(threadID: try object.requiredThreadID()) + case .threadSettingsUpdated: + try validateThreadSettings(object.requireObject("threadSettings")) + return .init(threadID: try object.requiredThreadID()) + case .hookStarted, .hookCompleted: + try validateHookRun(object.requireObject("run")) + _ = try object.optionalString("turnId") + return .init(threadID: try object.requiredThreadID()) + case .itemAutoApprovalReviewStarted: + try validateAutoApprovalReview(object, completed: false) + return try object.threadTurnContext() + case .itemAutoApprovalReviewCompleted: + try validateAutoApprovalReview(object, completed: true) + return try object.threadTurnContext() + case .rawResponseItemCompleted: + try validateResponseItem(object.requireObject("item")) + return try object.threadTurnContext() + case .commandExecOutputDelta: + _ = try object.requireBool("capReached") + _ = try object.requireString("deltaBase64") + _ = try object.requireNonEmptyString("processId") + _ = try object.requireEnum("stream", allowed: ["stdout", "stderr"]) + return .init() + case .processOutputDelta: + _ = try object.requireBool("capReached") + _ = try object.requireString("deltaBase64") + _ = try object.requireNonEmptyString("processHandle") + _ = try object.requireEnum("stream", allowed: ["stdout", "stderr"]) + return .init() + case .processExited: + _ = try object.requireInt("exitCode") + _ = try object.requireNonEmptyString("processHandle") + _ = try object.requireString("stderr") + _ = try object.requireBool("stderrCapReached") + _ = try object.requireString("stdout") + _ = try object.requireBool("stdoutCapReached") + return .init() + case .itemCommandExecutionTerminalInteraction: + _ = try object.requireNonEmptyString("itemId") + _ = try object.requireNonEmptyString("processId") + _ = try object.requireString("stdin") + return try object.threadTurnContext() + case .mcpServerOAuthLoginCompleted: + _ = try object.requireNonEmptyString("name") + _ = try object.requireBool("success") + _ = try object.optionalString("error") + _ = try object.optionalString("threadId") + return .init() + case .mcpServerStartupStatusUpdated: + _ = try object.requireNonEmptyString("name") + _ = try object.requireEnum( + "status", + allowed: ["starting", "ready", "failed", "cancelled"] + ) + _ = try object.optionalString("error") + _ = try object.optionalEnum( + "failureReason", + allowed: ["reauthenticationRequired"] + ) + _ = try object.optionalString("threadId") + return Self.context(from: object) + case .appListUpdated: + try validateAppList(object.requireArray("data")) + return .init() + case .remoteControlStatusChanged: + _ = try object.requireNonEmptyString("installationId") + _ = try object.requireNonEmptyString("serverName") + _ = try object.requireEnum( + "status", + allowed: ["disabled", "connecting", "connected", "errored"] + ) + return .init() + case .externalAgentConfigImportProgress, .externalAgentConfigImportCompleted: + _ = try object.requireNonEmptyString("importId") + try validateExternalImportResults(object.requireArray("itemTypeResults")) + return .init() + case .fsChanged: + _ = try object.requireStringArray("changedPaths") + _ = try object.requireNonEmptyString("watchId") + return .init() + case .threadCompacted: + return try object.threadTurnContext() + case .fuzzyFileSearchSessionUpdated: + _ = try object.requireArray("files") + _ = try object.requireString("query") + _ = try object.requireNonEmptyString("sessionId") + return .init() + case .fuzzyFileSearchSessionCompleted: + _ = try object.requireNonEmptyString("sessionId") + return .init() + case .threadRealtimeStarted: + _ = try object.requireEnum("version", allowed: ["v1", "v2"]) + _ = try object.optionalString("realtimeSessionId") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeItemAdded: + _ = try object.requireValue("item") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeTranscriptDelta: + _ = try object.requireString("delta") + _ = try object.requireString("role") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeTranscriptDone: + _ = try object.requireString("role") + _ = try object.requireString("text") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeOutputAudioDelta: + _ = try object.requireString("audio") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeSDP: + _ = try object.requireString("sdp") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeError: + _ = try object.requireString("message") + return .init(threadID: try object.requiredThreadID()) + case .threadRealtimeClosed: + _ = try object.optionalString("reason") + return .init(threadID: try object.requiredThreadID()) + } + } + + private func validateTurnPlan(_ values: [AppServerJSONValue]) throws { + for value in values { + let step = try PayloadObject.object(value, path: "plan[]") + _ = try step.requireString("step") + _ = try step.requireEnum( + "status", + allowed: ["pending", "inProgress", "completed"] + ) + } + } + + private func validateFileChanges(_ values: [AppServerJSONValue]) throws { + for value in values { + let change = try PayloadObject.object(value, path: "changes[]") + _ = try change.requireString("diff") + _ = try change.requireString("path") + let kind = try change.requireObject("kind") + let type = try kind.requireEnum("type", allowed: ["add", "delete", "update"]) + if type == "update" { + _ = try kind.optionalString("move_path") + } + } + } + + private func validateThreadGoal(_ goal: PayloadObject) throws { + _ = try goal.requireInt64("createdAt") + _ = try goal.requireString("objective") + _ = try goal.requireEnum( + "status", + allowed: ["active", "paused", "blocked", "usageLimited", "budgetLimited", "complete"] + ) + _ = try goal.requireNonEmptyString("threadId") + _ = try goal.requireInt64("timeUsedSeconds") + _ = try goal.optionalInt64("tokenBudget") + _ = try goal.requireInt64("tokensUsed") + _ = try goal.requireInt64("updatedAt") + } + + private func validateThreadSettings(_ settings: PayloadObject) throws { + let approvalPolicy = try settings.requireValue("approvalPolicy") + switch approvalPolicy { + case .string(let value): + guard ["untrusted", "on-request", "never"].contains(value) else { + throw NotificationContractError.illegalValue( + key: "threadSettings.approvalPolicy", + value: value + ) + } + case .object(let values): + let granular = try PayloadObject(values: values).requireObject("granular") + _ = try granular.requireBool("mcp_elicitations") + _ = try granular.requireBool("rules") + _ = try granular.requireBool("sandbox_approval") + default: + throw NotificationContractError.typeMismatch("threadSettings.approvalPolicy") + } + _ = try settings.requireEnum( + "approvalsReviewer", + allowed: ["user", "auto_review", "guardian_subagent"] + ) + let collaboration = try settings.requireObject("collaborationMode") + _ = try collaboration.requireEnum("mode", allowed: ["plan", "default"]) + _ = try collaboration.requireObject("settings").requireString("model") + _ = try settings.requireString("cwd") + _ = try settings.requireString("model") + _ = try settings.requireString("modelProvider") + let sandbox = try settings.requireObject("sandboxPolicy") + _ = try sandbox.requireEnum( + "type", + allowed: ["dangerFullAccess", "readOnly", "externalSandbox", "workspaceWrite"] + ) + } + + private func validateHookRun(_ run: PayloadObject) throws { + _ = try run.requireInt64("displayOrder") + for value in try run.requireArray("entries") { + let entry = try PayloadObject.object(value, path: "run.entries[]") + _ = try entry.requireEnum( + "kind", + allowed: ["warning", "stop", "feedback", "context", "error"] + ) + _ = try entry.requireString("text") + } + _ = try run.requireEnum( + "eventName", + allowed: [ + "preToolUse", "permissionRequest", "postToolUse", "preCompact", "postCompact", + "sessionStart", "userPromptSubmit", "subagentStart", "subagentStop", "stop", + ] + ) + _ = try run.requireEnum("executionMode", allowed: ["sync", "async"]) + _ = try run.requireEnum("handlerType", allowed: ["command", "prompt", "agent"]) + _ = try run.requireNonEmptyString("id") + _ = try run.requireEnum("scope", allowed: ["thread", "turn"]) + _ = try run.requireString("sourcePath") + _ = try run.requireInt64("startedAt") + _ = try run.requireEnum( + "status", + allowed: ["running", "completed", "failed", "blocked", "stopped"] + ) + } + + private func validateResponseItem(_ item: PayloadObject) throws { + let type = try item.requireEnum( + "type", + allowed: [ + "message", "agent_message", "reasoning", "local_shell_call", "function_call", + "tool_search_call", "function_call_output", "custom_tool_call", + "custom_tool_call_output", "tool_search_output", "web_search_call", + "image_generation_call", "compaction", "compaction_trigger", + "context_compaction", "other", + ] + ) + switch type { + case "message": + _ = try item.requireArray("content") + _ = try item.requireString("role") + case "agent_message": + _ = try item.requireString("author") + _ = try item.requireArray("content") + _ = try item.requireString("recipient") + case "reasoning": + _ = try item.requireArray("summary") + case "local_shell_call": + _ = try item.requireObject("action") + _ = try item.requireEnum( + "status", + allowed: ["completed", "in_progress", "incomplete"] + ) + case "function_call": + for key in ["arguments", "call_id", "name"] { + _ = try item.requireString(key) + } + case "tool_search_call": + _ = try item.requireValue("arguments") + _ = try item.requireString("execution") + case "function_call_output": + _ = try item.requireString("call_id") + _ = try item.requireValue("output") + case "custom_tool_call": + for key in ["call_id", "input", "name"] { + _ = try item.requireString(key) + } + case "custom_tool_call_output": + _ = try item.requireString("call_id") + _ = try item.requireValue("output") + case "tool_search_output": + _ = try item.requireString("execution") + _ = try item.requireString("status") + _ = try item.requireArray("tools") + case "web_search_call", "compaction_trigger", "context_compaction", "other": + break + case "image_generation_call": + _ = try item.requireString("result") + _ = try item.requireString("status") + case "compaction": + _ = try item.requireString("encrypted_content") + default: + preconditionFailure("Response item discriminator was validated above.") + } + } + + private func validateAppList(_ values: [AppServerJSONValue]) throws { + for value in values { + let app = try PayloadObject.object(value, path: "data[]") + _ = try app.requireNonEmptyString("id") + _ = try app.requireString("name") + } + } + + private func validateExternalImportResults(_ values: [AppServerJSONValue]) throws { + let itemTypes: Set = [ + "AGENTS_MD", "CONFIG", "SKILLS", "PLUGINS", "MCP_SERVER_CONFIG", "SUBAGENTS", + "HOOKS", "COMMANDS", "SESSIONS", + ] + for value in values { + let result = try PayloadObject.object(value, path: "itemTypeResults[]") + _ = try result.requireEnum("itemType", allowed: itemTypes) + for failureValue in try result.requireArray("failures") { + let failure = try PayloadObject.object( + failureValue, + path: "itemTypeResults[].failures[]" + ) + _ = try failure.requireString("failureStage") + _ = try failure.requireEnum("itemType", allowed: itemTypes) + _ = try failure.requireString("message") + } + for successValue in try result.requireArray("successes") { + let success = try PayloadObject.object( + successValue, + path: "itemTypeResults[].successes[]" + ) + _ = try success.requireEnum("itemType", allowed: itemTypes) + } + } + } + + private func decodeTurn( + from object: PayloadObject, + data: Data, + lifecycle: TurnLifecycle + ) throws -> AppServerAPI.Turn.Payload { + let turnObject = try object.requireObject("turn") + try validateTurn(turnObject, lifecycle: lifecycle) + let turnData = try JSONEncoder().encode(try object.requireValue("turn")) + return try decoder.decode(AppServerAPI.Turn.Payload.self, from: turnData) + } + + private func decodeTurnDiagnostic(from object: PayloadObject) throws -> CodexTurnDiagnostic { + let errorData = try JSONEncoder().encode(try object.requireValue("error")) + let error = try decoder.decode(AppServerAPI.Turn.Error.self, from: errorData) + return .init( + error: CodexAppServer.turnError(from: error), + willRetry: try object.requireBool("willRetry") + ) + } + + private enum TurnLifecycle { + case started + case completed + } + + private func validateTurn(_ turn: PayloadObject, lifecycle: TurnLifecycle) throws { + _ = try turn.requireNonEmptyString("id") + let status = try turn.requireNonEmptyString("status") + let items = try turn.requireArray("items") + for item in items { + guard case .object(let values) = item else { + throw NotificationContractError.expectedObject("turn.items[]") + } + try validateThreadItem(.init(values: values), lifecycle: nil) + } + if case .started = lifecycle, CodexTurnStatus(rawValue: status) != .inProgress { + throw NotificationContractError.illegalValue( + key: "turn.status", + value: status + ) + } + } + + private func decodeItem( + from object: PayloadObject, + lifecycle: ItemLifecycle + ) throws -> CodexItemReducer.Mutation { + let itemObject = try object.requireObject("item") + let validationLifecycle: TurnLifecycle? = switch lifecycle { + case .started: .started + case .completed: .completed + } + try validateThreadItem(itemObject, lifecycle: validationLifecycle) + let itemData = try JSONEncoder().encode(try object.requireValue("item")) + let rawItem = try decoder.decode(RawThreadItem.self, from: itemData) + let item: CodexThreadItem? + switch lifecycle { + case .started(let date): + item = rawItem.makeThreadItem(startedAt: date, completedAt: nil) + case .completed(let date): + item = rawItem.makeThreadItem(startedAt: nil, completedAt: date) + } + guard let item else { + throw NotificationContractError.missingRequired("item.id") + } + return switch lifecycle { + case .started: .started(item) + case .completed: .completed(item) + } + } + + private func validateThreadItem( + _ item: PayloadObject, + lifecycle: TurnLifecycle? + ) throws { + _ = try item.requireNonEmptyString("id") + let type = try item.requireNonEmptyString("type") + let requiredFields: [(String, PayloadObject.ValueKind)] + let statusValues: Set? + switch type { + case "userMessage": + requiredFields = [("content", .array)] + statusValues = nil + case "hookPrompt": + requiredFields = [("fragments", .array)] + statusValues = nil + case "agentMessage", "plan": + requiredFields = [("text", .string)] + statusValues = nil + case "reasoning", "contextCompaction": + requiredFields = [] + statusValues = nil + case "commandExecution": + requiredFields = [ + ("command", .string), + ("commandActions", .array), + ("cwd", .string), + ("status", .string), + ] + statusValues = ["inProgress", "completed", "failed", "declined"] + case "fileChange": + requiredFields = [("changes", .any), ("status", .string)] + statusValues = ["inProgress", "completed", "failed", "declined"] + case "mcpToolCall": + requiredFields = [ + ("arguments", .any), + ("server", .string), + ("status", .string), + ("tool", .string), + ] + statusValues = ["inProgress", "completed", "failed"] + case "dynamicToolCall": + requiredFields = [ + ("arguments", .any), + ("status", .string), + ("tool", .string), + ] + statusValues = ["inProgress", "completed", "failed"] + case "collabAgentToolCall": + requiredFields = [ + ("agentsStates", .object), + ("receiverThreadIds", .array), + ("senderThreadId", .string), + ("status", .string), + ("tool", .string), + ] + statusValues = ["inProgress", "completed", "failed"] + case "subAgentActivity": + requiredFields = [ + ("agentPath", .string), + ("agentThreadId", .string), + ("kind", .string), + ] + statusValues = nil + case "webSearch": + requiredFields = [("query", .string)] + statusValues = nil + case "imageView": + requiredFields = [("path", .string)] + statusValues = nil + case "sleep": + requiredFields = [("durationMs", .int)] + statusValues = nil + case "imageGeneration": + requiredFields = [("result", .string), ("status", .string)] + statusValues = nil + case "enteredReviewMode", "exitedReviewMode": + requiredFields = [("review", .string)] + statusValues = nil + default: + return + } + for (key, kind) in requiredFields { + try item.require(key, kind: kind) + } + if type == "fileChange" { + try validateFileChanges(item.requireArray("changes")) + } + guard let statusValues else { + return + } + let status = try item.requireEnum("status", allowed: statusValues) + switch lifecycle { + case .started where status != "inProgress": + throw NotificationContractError.illegalValue(key: "item.status", value: status) + case .completed where status == "inProgress": + throw NotificationContractError.illegalValue(key: "item.status", value: status) + case nil, .started, .completed: + break + } + } + + private func validateThread(_ thread: PayloadObject) throws { + _ = try thread.requireNonEmptyString("id") + for key in ["cliVersion", "cwd", "modelProvider", "preview", "sessionId"] { + _ = try thread.requireString(key) + } + _ = try thread.requireInt64("createdAt") + _ = try thread.requireInt64("updatedAt") + _ = try thread.requireBool("ephemeral") + _ = try thread.requireValue("source") + _ = try decodeThreadStatus(from: thread.requireObject("status")) + let turns = try thread.requireArray("turns") + for turn in turns { + guard case .object(let values) = turn else { + throw NotificationContractError.expectedObject("thread.turns[]") + } + try validateTurn(.init(values: values), lifecycle: .completed) + } + } + + private func decodeThreadStatus(from status: PayloadObject) throws -> CodexThreadStatus { + let type = try status.requireNonEmptyString("type") + let activeFlags: [String]? + if type == "active" { + activeFlags = try status.requireStringArray("activeFlags") + } else { + activeFlags = nil + } + return .init(type: type, activeFlags: activeFlags) + } + + private func decodeTokenUsage(from tokenUsage: PayloadObject) throws -> CodexTokenUsage { + let last = try tokenUsage.requireObject("last") + let total = try tokenUsage.requireObject("total") + try validateTokenUsageBreakdown(last) + try validateTokenUsageBreakdown(total) + return .init( + inputTokens: try total.requireInt("inputTokens"), + outputTokens: try total.requireInt("outputTokens"), + totalTokens: try total.requireInt("totalTokens"), + cachedInputTokens: try total.requireInt("cachedInputTokens"), + reasoningOutputTokens: try total.requireInt("reasoningOutputTokens"), + modelContextWindow: try tokenUsage.optionalInt("modelContextWindow") + ) + } + + private func validateTokenUsageBreakdown(_ usage: PayloadObject) throws { + for key in [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", + ] { + _ = try usage.requireInt(key) + } + } + + private func decodeAccountUpdate(from object: PayloadObject) throws -> AccountUpdate { + let authMode = try object.optionalEnum( + "authMode", + allowed: Set(AccountUpdate.AuthMode.allRawValues) + ).flatMap(AccountUpdate.AuthMode.init(rawValue:)) + let planType = try object.optionalEnum( + "planType", + allowed: Set(AccountUpdate.PlanType.allRawValues) + ).flatMap(AccountUpdate.PlanType.init(rawValue:)) + return .init(authMode: authMode, planType: planType) + } + + private func decodeRateLimitSnapshot( + from object: PayloadObject + ) throws -> AppServerAPI.Account.RateLimits.Snapshot { + _ = try object.optionalString("limitId") + _ = try object.optionalString("limitName") + if let primary = try object.optionalObject("primary") { + try validateRateLimitWindow(primary) + } + if let secondary = try object.optionalObject("secondary") { + try validateRateLimitWindow(secondary) + } + if let credits = try object.optionalObject("credits") { + _ = try credits.requireBool("hasCredits") + _ = try credits.requireBool("unlimited") + _ = try credits.optionalString("balance") + } + if let limit = try object.optionalObject("individualLimit") { + _ = try limit.requireString("limit") + _ = try limit.requireInt("remainingPercent") + _ = try limit.requireInt64("resetsAt") + _ = try limit.requireString("used") + } + _ = try object.optionalEnum( + "planType", + allowed: Set(AccountUpdate.PlanType.allRawValues) + ) + _ = try object.optionalEnum( + "rateLimitReachedType", + allowed: [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached", + ] + ) + let data = try JSONEncoder().encode(AppServerJSONValue.object(object.values)) + return try decoder.decode(AppServerAPI.Account.RateLimits.Snapshot.self, from: data) + } + + private func validateRateLimitWindow(_ window: PayloadObject) throws { + _ = try window.requireInt("usedPercent") + _ = try window.optionalInt("windowDurationMins") + _ = try window.optionalInt64("resetsAt") + } + + private func validateAutoApprovalReview( + _ object: PayloadObject, + completed: Bool + ) throws { + try validateAutoApprovalAction(object.requireObject("action")) + let review = try object.requireObject("review") + _ = try review.requireEnum( + "status", + allowed: ["inProgress", "approved", "denied", "timedOut", "aborted"] + ) + _ = try object.requireNonEmptyString("reviewId") + _ = try object.requireInt64("startedAtMs") + if completed { + _ = try object.requireInt64("completedAtMs") + _ = try object.requireEnum("decisionSource", allowed: ["agent"]) + } + } + + private func validateAutoApprovalAction(_ action: PayloadObject) throws { + let type = try action.requireEnum( + "type", + allowed: [ + "command", "execve", "applyPatch", "networkAccess", "mcpToolCall", + "requestPermissions", + ] + ) + switch type { + case "command": + _ = try action.requireString("command") + _ = try action.requireString("cwd") + _ = try action.requireEnum("source", allowed: ["shell", "unifiedExec"]) + case "execve": + _ = try action.requireStringArray("argv") + _ = try action.requireString("cwd") + _ = try action.requireString("program") + _ = try action.requireEnum("source", allowed: ["shell", "unifiedExec"]) + case "applyPatch": + _ = try action.requireString("cwd") + _ = try action.requireStringArray("files") + case "networkAccess": + _ = try action.requireString("host") + _ = try action.requireInt("port") + _ = try action.requireEnum( + "protocol", + allowed: ["http", "https", "socks5Tcp", "socks5Udp"] + ) + _ = try action.requireString("target") + case "mcpToolCall": + _ = try action.requireString("server") + _ = try action.requireString("toolName") + case "requestPermissions": + _ = try action.requireObject("permissions") + default: + preconditionFailure("Auto-approval action discriminator was validated above.") + } + } + + private static func context(from object: PayloadObject) -> Context { + let threadID = object.string("threadId") + .flatMap(Self.nonEmpty) + .map { CodexThreadID(rawValue: $0) } + let turnID = object.string("turnId") + .flatMap(Self.nonEmpty) + .map { CodexTurnID(rawValue: $0) } + return .init(threadID: threadID, turnID: turnID) + } + + private static func bestEffortContext(from data: Data) -> Context { + guard let value = try? JSONDecoder().decode(AppServerJSONValue.self, from: data), + case .object(let values) = value + else { + return .init() + } + return context(from: .init(values: values)) + } + + private static func nonEmpty(_ value: String) -> String? { + value.isEmpty ? nil : value + } + + private static func date(millisecondsSince1970 milliseconds: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1_000) + } +} + +private extension AppServerNotificationDecoder.AccountUpdate.AuthMode { + static var allRawValues: [String] { + [ + apiKey.rawValue, + chatGPT.rawValue, + chatGPTAuthTokens.rawValue, + headers.rawValue, + agentIdentity.rawValue, + personalAccessToken.rawValue, + bedrockAPIKey.rawValue, + ] + } +} + +private extension AppServerNotificationDecoder.AccountUpdate.PlanType { + static var allRawValues: [String] { + [ + free.rawValue, + go.rawValue, + plus.rawValue, + pro.rawValue, + prolite.rawValue, + team.rawValue, + selfServeBusinessUsageBased.rawValue, + business.rawValue, + enterpriseCBPUsageBased.rawValue, + enterprise.rawValue, + edu.rawValue, + unknown.rawValue, + ] + } +} + +private struct PayloadObject { + enum ValueKind { + case any + case string + case int + case bool + case array + case object + } + + var values: [String: AppServerJSONValue] + + init(values: [String: AppServerJSONValue]) { + self.values = values + } + + init(data: Data, decoder: JSONDecoder) throws { + let value = try decoder.decode(AppServerJSONValue.self, from: data) + guard case .object(let values) = value else { + throw NotificationContractError.expectedObject("params") + } + self.values = values + } + + static func object(_ value: AppServerJSONValue, path: String) throws -> PayloadObject { + guard case .object(let values) = value else { + throw NotificationContractError.expectedObject(path) + } + return .init(values: values) + } + + func requireValue(_ key: String) throws -> AppServerJSONValue { + guard let value = values[key] else { + throw NotificationContractError.missingRequired(key) + } + return value + } + + func require(_ key: String, kind: ValueKind) throws { + let value = try requireValue(key) + let matches = switch (kind, value) { + case (.any, _), + (.string, .string), + (.int, .int), + (.bool, .bool), + (.array, .array), + (.object, .object): + true + default: + false + } + guard matches else { + throw NotificationContractError.typeMismatch(key) + } + } + + func requireString(_ key: String) throws -> String { + guard case .string(let value) = try requireValue(key) else { + throw NotificationContractError.typeMismatch(key) + } + return value + } + + func requireNonEmptyString(_ key: String) throws -> String { + let value = try requireString(key) + guard value.isEmpty == false else { + throw NotificationContractError.missingRequired(key) + } + return value + } + + func requireNonWhitespaceString(_ key: String) throws -> String { + let value = try requireNonEmptyString(key) + guard value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw NotificationContractError.missingRequired(key) + } + return value + } + + func requireInt(_ key: String) throws -> Int { + guard case .int(let value) = try requireValue(key) else { + throw NotificationContractError.typeMismatch(key) + } + return value + } + + func requireInt64(_ key: String) throws -> Int64 { + let value = try requireInt(key) + guard let result = Int64(exactly: value) else { + throw NotificationContractError.typeMismatch(key) + } + return result + } + + func requireBool(_ key: String) throws -> Bool { + guard case .bool(let value) = try requireValue(key) else { + throw NotificationContractError.typeMismatch(key) + } + return value + } + + func requireArray(_ key: String) throws -> [AppServerJSONValue] { + guard case .array(let value) = try requireValue(key) else { + throw NotificationContractError.typeMismatch(key) + } + return value + } + + func requireStringArray(_ key: String) throws -> [String] { + try requireArray(key).map { value in + guard case .string(let string) = value else { + throw NotificationContractError.typeMismatch("\(key)[]") + } + return string + } + } + + func requireObject(_ key: String) throws -> PayloadObject { + guard case .object(let value) = try requireValue(key) else { + throw NotificationContractError.typeMismatch(key) + } + return .init(values: value) + } + + func requireEnum(_ key: String, allowed: Set) throws -> String { + let value = try requireString(key) + guard allowed.contains(value) else { + throw NotificationContractError.illegalValue(key: key, value: value) + } + return value + } + + func requireRequestID(_ key: String) throws -> CodexServerRequestID { + let value = try requireValue(key) + switch value { + case .int(let value): + return .integer(Int64(value)) + case .string(let value): + return .string(value) + default: + throw NotificationContractError.typeMismatch(key) + } + } + + func optionalString(_ key: String) throws -> String? { + guard let value = values[key] else { + return nil + } + switch value { + case .null: + return nil + case .string(let string): + return string + default: + throw NotificationContractError.typeMismatch(key) + } + } + + func optionalInt(_ key: String) throws -> Int? { + guard let value = values[key] else { + return nil + } + switch value { + case .null: + return nil + case .int(let int): + return int + default: + throw NotificationContractError.typeMismatch(key) + } + } + + func optionalInt64(_ key: String) throws -> Int64? { + guard let value = try optionalInt(key) else { + return nil + } + guard let result = Int64(exactly: value) else { + throw NotificationContractError.typeMismatch(key) + } + return result + } + + func optionalObject(_ key: String) throws -> PayloadObject? { + guard let value = values[key] else { + return nil + } + switch value { + case .null: + return nil + case .object(let object): + return .init(values: object) + default: + throw NotificationContractError.typeMismatch(key) + } + } + + func optionalEnum(_ key: String, allowed: Set) throws -> String? { + guard let value = try optionalString(key) else { + return nil + } + guard allowed.contains(value) else { + throw NotificationContractError.illegalValue(key: key, value: value) + } + return value + } + + func string(_ key: String) -> String? { + guard case .string(let value) = values[key] else { + return nil + } + return value + } + + func requiredThreadID(key: String = "threadId") throws -> CodexThreadID { + .init(rawValue: try requireNonEmptyString(key)) + } + + func requiredTurnID(key: String = "turnId") throws -> CodexTurnID { + .init(rawValue: try requireNonEmptyString(key)) + } + + func threadTurnContext() throws -> AppServerNotificationDecoder.Context { + .init(threadID: try requiredThreadID(), turnID: try requiredTurnID()) + } +} + +private enum NotificationContractError: LocalizedError { + case missingRequired(String) + case typeMismatch(String) + case expectedObject(String) + case illegalValue(key: String, value: String) + + var errorDescription: String? { + switch self { + case .missingRequired(let key): + "Current-v2 notification is missing required field \(key)." + case .typeMismatch(let key): + "Current-v2 notification field \(key) has the wrong type." + case .expectedObject(let path): + "Current-v2 notification value \(path) must be an object." + case .illegalValue(let key, let value): + "Current-v2 notification field \(key) has illegal value \(value)." + } + } +} diff --git a/Sources/CodexAppServerKit/AppServerProcessTransport.swift b/Sources/CodexAppServerKit/AppServerProcessTransport.swift new file mode 100644 index 00000000..be2d4604 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerProcessTransport.swift @@ -0,0 +1,1816 @@ +import Darwin +import Foundation +import OSLog +import Synchronization + +private let logger = Logger(subsystem: "CodexAppServerKit", category: "app-server-transport") + +package actor AppServerProcessTransport: JSONRPC.Transport { + package nonisolated static let stdoutReadChunkByteCount = 64 * 1_024 + package nonisolated let connectionEventHub: ConnectionEventHub + + package struct Configuration: Sendable { + package var executable: String + package var arguments: [String] + package var environment: [String: String] + package var codexHomeURL: URL + + package init( + executable: String? = nil, + arguments: [String]? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + codexHomeURL: URL + ) { + let resolvedExecutable = executable.map { + CodexAppServerExecutable.resolveExecutable($0, environment: environment) + } ?? CodexAppServerExecutable.resolveExecutable( + environment: environment + ) + self.executable = resolvedExecutable + self.arguments = + arguments + ?? CodexAppServerExecutable.appServerArguments() + self.environment = AppServerCodexHome.environment( + environment, + codexHomeURL: codexHomeURL + ) + self.codexHomeURL = codexHomeURL + } + } + + private let process: AppServerSpawnedProcess + private let writer: AppServerJSONRPCWriter + private let mailbox: JSONRPCInboundFrameMailbox + private let terminationToken: ProcessTerminationToken + private let stdoutReadMetrics: AppServerStdoutReadMetrics + private let stdoutReaderTask: Task + private let stderrDrainTask: Task + private let processWaiterTask: Task + private var pending: [Int: JSONRPCResponseWaiter] = [:] + private var acceptingOutbound = true + private var closeStarted = false + private var inboundTerminalObserved = false + + package nonisolated var processTerminationToken: ProcessTerminationToken { + terminationToken + } + + package init( + configuration: Configuration, + connectionEventHub: ConnectionEventHub, + writerFactory: @Sendable (FileHandle) -> AppServerJSONRPCWriter = { + AppServerJSONRPCWriter(fileHandle: $0) + } + ) throws { + guard FileManager.default.isExecutableFile(atPath: configuration.executable) else { + throw CodexLaunchFailure.executableNotFound( + command: configuration.executable, + searchedPath: configuration.environment["PATH"] + ) + } + do { + try AppServerCodexHome.ensureScaffold(at: configuration.codexHomeURL) + } catch { + throw CodexLaunchFailure.scaffold( + path: configuration.codexHomeURL.path, + message: error.localizedDescription + ) + } + let launch: AppServerProcessLaunch + do { + launch = try AppServerSpawnedProcess.launch( + executable: configuration.executable, + arguments: configuration.arguments, + environment: configuration.environment + ) + } catch { + throw CodexLaunchFailure.spawn( + executable: configuration.executable, + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + ) + } + let process = launch.process + let stdin = launch.stdin + let stdout = launch.stdout + let stderr = launch.stderr + self.process = process + self.connectionEventHub = connectionEventHub + let writer = writerFactory(stdin.fileHandleForWriting) + self.writer = writer + let mailbox = JSONRPCInboundFrameMailbox() + self.mailbox = mailbox + let stdoutReadMetrics = AppServerStdoutReadMetrics() + self.stdoutReadMetrics = stdoutReadMetrics + let terminationToken = ProcessTerminationToken(processGroupID: process.processIdentifier) + self.terminationToken = terminationToken + self.stdoutReaderTask = Task { + await Self.readStdout( + stdout.fileHandleForReading, + into: mailbox, + metrics: stdoutReadMetrics + ) + } + self.stderrDrainTask = Task { + await Self.drainStderr( + stderr.fileHandleForReading, + connectionEventHub: connectionEventHub + ) + } + self.processWaiterTask = Task { + await process.waitForExit(terminationToken: terminationToken) + } + logger.info( + "Launching codex app-server: \(configuration.executable, privacy: .public) \(configuration.arguments.joined(separator: " "), privacy: .public)" + ) + logger.info( + "Using codex app-server home: \(configuration.codexHomeURL.path, privacy: .public)") + logger.info( + "codex app-server launched with pid \(process.processIdentifier, privacy: .public)") + } + + package func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + try Task.checkCancellation() + try throwIfNotAcceptingOutbound() + precondition( + pending[request.id] == nil, + "JSON-RPC request IDs must be unique while a response is pending." + ) + let payload = try makeRequestPayload(request) + try acceptWrite() + + let waiter = JSONRPCResponseWaiter() + pending[request.id] = waiter + do { + try writer.write(payload) + } catch { + pending.removeValue(forKey: request.id) + let failure = Self.transportFailure(from: error) + await claimTerminal(failure) + throw JSONRPC.OutboundWriteFailure(failure) + } + return try await waiter.wait() + } + + package func notify(_ notification: JSONRPC.Notification) async throws { + try Task.checkCancellation() + try throwIfNotAcceptingOutbound() + let payload = try makeNotificationPayload(notification) + do { + try writer.write(payload) + } catch { + let failure = Self.transportFailure(from: error) + await claimTerminal(failure) + throw failure + } + } + + package func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + while true { + let frame: Data + do { + guard let next = try await mailbox.next() else { + inboundTerminalObserved = true + return nil + } + frame = next + } catch { + let snapshot = await mailbox.snapshot() + if snapshot.isTerminal, snapshot.acceptedFrameCount == 0 { + inboundTerminalObserved = true + } + throw error + } + + switch try JSONRPC.decodeInboundEnvelope(frame) { + case .response(let id, let result): + guard let waiter = pending.removeValue(forKey: id) else { + if acceptingOutbound == false { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.lateResponse(requestID: id) + )) + logger.warning( + "Ignoring late JSON-RPC response \(id, privacy: .public) after outbound close" + ) + continue + } + let failure = CodexTransportFailure.protocolViolation( + message: "Received a JSON-RPC response for unknown request id \(id).", + rawData: frame + ) + await claimTerminal(failure) + throw failure + } + waiter.resolve(result) + case .event(let event): + return event + } + } + } + + package func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws { + try throwIfNotAcceptingOutbound() + do { + try writer.write(Self.serverRequestResponsePayload(id: requestID, response: response)) + } catch { + let failure = Self.transportFailure(from: error) + await claimTerminal(failure) + throw failure + } + } + + package func beginClose() async -> JSONRPC.ProcessExitObservation? { + guard closeStarted == false else { + return process.observedExitForCloseArbitration() + } + closeStarted = true + acceptingOutbound = false + let exitObservation = process.markTerminationStarted() + writer.close() + await mailbox.finish() + logger.info( + "Terminating codex app-server pid \(self.process.processIdentifier, privacy: .public)" + ) + terminationToken.terminateOnce() + return exitObservation + } + + package func finishPendingResponsesAfterInboundDrain( + _ failure: CodexTransportFailure + ) { + precondition( + inboundTerminalObserved, + "Pending responses can finish only after inbound terminal was observed." + ) + let responseFailure: JSONRPC.Error + switch failure { + case .closed: + responseFailure = .closed + case .io, .framing, .protocolViolation, .contractViolation: + responseFailure = .invalidMessage(failure.localizedDescription) + } + let waiters = pending.values + pending.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resolve(.failure(responseFailure)) + } + } + + package func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { + await processWaiterTask.value + } + + package func waitUntilClosed() async { + await stdoutReaderTask.value + await stderrDrainTask.value + } + + package func reapProcess() async { + await process.reap() + } + + package func processLifecycleSnapshotForTesting() -> AppServerProcessLifecycleSnapshot { + process.lifecycleSnapshot() + } + + package func inboundMailboxSnapshotForTesting() async -> JSONRPCInboundFrameMailbox.Snapshot { + await mailbox.snapshot() + } + + package func waitForInboundAdmissionWaiterCountForTesting(atLeast minimumCount: Int) async { + await mailbox.waitForAdmissionWaiterCount(atLeast: minimumCount) + } + + package func waitUntilInboundReceiverIsRegisteredForTesting() async { + await mailbox.waitUntilReceiverIsRegistered() + } + + package func stdoutReadSnapshotForTesting() -> AppServerStdoutReadSnapshot { + stdoutReadMetrics.snapshot() + } + + package static func responsePayloadData(from result: Any) throws -> Data { + try JSONRPC.payloadData(from: result) + } + + package static func serverRequestResponsePayload( + id: CodexServerRequestID, + response: CodexServerRequestResponse + ) throws -> Data { + let payload: [String: Any] + switch response { + case .result(let result): + payload = [ + "id": id.jsonObject, + "result": try JSONSerialization.jsonObject( + with: result, + options: [.fragmentsAllowed] + ), + ] + case .error(let code, let message): + payload = [ + "id": id.jsonObject, + "error": [ + "code": code, + "message": message, + ], + ] + } + var data = try JSONSerialization.data(withJSONObject: payload) + data.append(0x0A) + return data + } + + private func throwIfNotAcceptingOutbound() throws { + if acceptingOutbound == false { + throw JSONRPC.Error.closed + } + } + + private func claimTerminal(_ failure: CodexTransportFailure) async { + if acceptingOutbound { + acceptingOutbound = false + writer.close() + } + await mailbox.finish(throwing: failure) + } + + private nonisolated static func transportFailure(from error: Error) -> CodexTransportFailure { + if let failure = error as? CodexTransportFailure { + return failure + } + if let error = error as? JSONRPC.Error, error == .closed { + return .closed + } + return .io( + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + ) + } + + private nonisolated static func readStdout( + _ fileHandle: FileHandle, + into mailbox: JSONRPCInboundFrameMailbox, + metrics: AppServerStdoutReadMetrics + ) async { + var framer = JSONRPC.Framer() + var currentChunkRemainderByteCount = 0 + let eventSource: AppServerPipeReadEventSource + do { + try makeNonblocking(fileHandle.fileDescriptor) + eventSource = AppServerPipeReadEventSource( + fileHandle: fileHandle, + label: "app-server-stdout", + onCancel: { + metrics.sourceCancellationCompleted() + } + ) + } catch { + try? fileHandle.close() + await mailbox.finish(throwing: .io( + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + )) + return + } + do { + stdoutEvents: while true { + try Task.checkCancellation() + switch await eventSource.next() { + case .ready: + readLoop: while true { + switch try readNonblockingChunk( + fileHandle.fileDescriptor, + maximumByteCount: stdoutReadChunkByteCount + ) { + case .data(let data): + metrics.beginChunk(byteCount: data.count) + currentChunkRemainderByteCount = data.count + for byte in data { + currentChunkRemainderByteCount -= 1 + let frame = try framer.append(byte) + if let frame { + metrics.updateRemainder( + byteCount: currentChunkRemainderByteCount + ) + try await mailbox.send(frame) + } + } + metrics.updateRemainder(byteCount: 0) + case .wouldBlock: + break readLoop + case .end: + if let frame = framer.finish() { + try await mailbox.send(frame) + } + await mailbox.finish() + break stdoutEvents + } + } + case .cancelled: + throw CancellationError() + } + } + } catch is CancellationError { + metrics.dropRemainder(byteCount: currentChunkRemainderByteCount) + await mailbox.finish(throwing: .closed) + } catch let failure as CodexTransportFailure { + metrics.dropRemainder(byteCount: currentChunkRemainderByteCount) + await mailbox.finish(throwing: failure) + } catch { + metrics.dropRemainder(byteCount: currentChunkRemainderByteCount) + await mailbox.finish(throwing: .io( + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + )) + } + await eventSource.cancelAndWait() + } + + private nonisolated static func drainStderr( + _ fileHandle: FileHandle, + connectionEventHub: ConnectionEventHub + ) async { + var filter = AppServerStderrLogFilter() + let eventSource: AppServerPipeReadEventSource + do { + try makeNonblocking(fileHandle.fileDescriptor) + eventSource = AppServerPipeReadEventSource( + fileHandle: fileHandle, + label: "app-server-stderr" + ) + } catch { + try? fileHandle.close() + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.processStderrFailure( + .setup, + details: error.localizedDescription + ) + )) + logger.error( + "codex app-server stderr setup failed: \(error.localizedDescription, privacy: .public)" + ) + return + } + do { + eventLoop: while true { + try Task.checkCancellation() + switch await eventSource.next() { + case .ready: + readLoop: while true { + switch try readNonblockingChunk(fileHandle.fileDescriptor) { + case .data(let data): + for event in filter.append(data) { + logStderr(event, connectionEventHub: connectionEventHub) + } + case .wouldBlock: + break readLoop + case .end: + break eventLoop + } + } + case .cancelled: + throw CancellationError() + } + } + } catch is CancellationError { + } catch { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.processStderrFailure( + .read, + details: error.localizedDescription + ) + )) + logger.error( + "codex app-server stderr read failed: \(error.localizedDescription, privacy: .public)" + ) + } + await eventSource.cancelAndWait() + for event in filter.finish() { + logStderr(event, connectionEventHub: connectionEventHub) + } + } + + private enum NonblockingChunkRead { + case data(Data) + case wouldBlock + case end + } + + private nonisolated static func makeNonblocking(_ fileDescriptor: Int32) throws { + let flags = fcntl(fileDescriptor, F_GETFL) + guard flags != -1 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard fcntl(fileDescriptor, F_SETFL, flags | O_NONBLOCK) != -1 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + + private nonisolated static func readNonblockingChunk( + _ fileDescriptor: Int32, + maximumByteCount: Int = 16 * 1_024 + ) throws -> NonblockingChunkRead { + precondition(maximumByteCount > 0) + var bytes = [UInt8](repeating: 0, count: maximumByteCount) + let count: Int + while true { + let result = bytes.withUnsafeMutableBytes { buffer in + Darwin.read(fileDescriptor, buffer.baseAddress, buffer.count) + } + if result == -1, errno == EINTR { + continue + } + count = result + break + } + if count > 0 { + return .data(Data(bytes.prefix(count))) + } + if count == 0 { + return .end + } + if errno == EAGAIN || errno == EWOULDBLOCK { + return .wouldBlock + } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + private nonisolated static func logStderr( + _ event: AppServerStderrLogFilter.Event, + connectionEventHub: ConnectionEventHub + ) { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.processStderr(event) + )) + switch event.level { + case .error: + logger.error("codex app-server stderr: \(event.message, privacy: .public)") + case .warning: + logger.warning("codex app-server stderr: \(event.message, privacy: .public)") + } + } +} + +private enum AppServerReadEvent: Sendable { + case ready + case cancelled +} + +private final class AppServerOneBitReadSignal: Sendable { + private struct State { + var hasPendingReadiness = false + var isCancelled = false + var waiter: AppServerReadEventWaiter? + } + + private let state = Mutex(State()) + + func next() async -> AppServerReadEvent { + if Task.isCancelled { + return .cancelled + } + let waiter = AppServerReadEventWaiter() + let immediate = state.withLock { state -> AppServerReadEvent? in + if state.hasPendingReadiness { + state.hasPendingReadiness = false + return .ready + } + if state.isCancelled { + return .cancelled + } + precondition(state.waiter == nil, "Read signal supports one consumer.") + state.waiter = waiter + return nil + } + if let immediate { + return immediate + } + let event = await waiter.wait() + state.withLock { state in + if state.waiter?.id == waiter.id { + state.waiter = nil + } + } + return event + } + + func signalReadiness() { + let waiter = state.withLock { state -> AppServerReadEventWaiter? in + guard state.isCancelled == false else { + return nil + } + if let waiter = state.waiter { + state.waiter = nil + return waiter + } + state.hasPendingReadiness = true + return nil + } + if let waiter, waiter.resolve(.ready) == false { + state.withLock { state in + if state.isCancelled == false { + state.hasPendingReadiness = true + } + } + } + } + + func cancel() { + let waiter = state.withLock { state -> AppServerReadEventWaiter? in + guard state.isCancelled == false else { + return nil + } + state.isCancelled = true + state.hasPendingReadiness = false + defer { state.waiter = nil } + return state.waiter + } + _ = waiter?.resolve(.cancelled) + } +} + +private final class AppServerReadEventWaiter: Sendable { + private enum State { + case pending(CheckedContinuation?) + case resolved(AppServerReadEvent) + } + + let id = UUID() + private let state = Mutex(.pending(nil)) + + func wait() async -> AppServerReadEvent { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> AppServerReadEvent? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("Read waiter registered more than once.") + case .resolved(let event): + return event + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + _ = self.resolve(.cancelled) + } + } + + @discardableResult + func resolve(_ event: AppServerReadEvent) -> Bool { + let result = state.withLock { + state -> (Bool, CheckedContinuation?) in + switch state { + case .pending(let continuation): + state = .resolved(event) + return (true, continuation) + case .resolved: + return (false, nil) + } + } + result.1?.resume(returning: event) + return result.0 + } +} + +private final class AppServerPipeReadEventSource: Sendable { + private struct State { + var source: DispatchSourceRead? + } + + private let signal = AppServerOneBitReadSignal() + private let cancellationCompletion = AppServerCancellationCompletion() + private let state: Mutex + + init( + fileHandle: FileHandle, + label: String, + onCancel: @escaping @Sendable () -> Void = {} + ) { + let queue = DispatchQueue(label: "CodexAppServerKit.\(label)") + let source = DispatchSource.makeReadSource( + fileDescriptor: fileHandle.fileDescriptor, + queue: queue + ) + self.state = Mutex(.init(source: source)) + let signal = signal + source.setEventHandler { + signal.signalReadiness() + } + let cancellationCompletion = cancellationCompletion + source.setCancelHandler { + try? fileHandle.close() + onCancel() + signal.cancel() + cancellationCompletion.complete() + } + source.resume() + } + + deinit { + cancel() + } + + func next() async -> AppServerReadEvent { + await signal.next() + } + + func cancel() { + let source = state.withLock { state in + defer { state.source = nil } + return state.source + } + source?.cancel() + } + + func cancelAndWait() async { + cancel() + await cancellationCompletion.wait() + } +} + +package struct AppServerStdoutReadSnapshot: Equatable, Sendable { + package var successfulReadCount: Int + package var maximumChunkByteCount: Int + package var currentChunkRemainderByteCount: Int + package var droppedRemainderByteCount: Int + package var sourceCancellationCompleted: Bool +} + +private final class AppServerStdoutReadMetrics: Sendable { + private struct State { + var successfulReadCount = 0 + var maximumChunkByteCount = 0 + var currentChunkRemainderByteCount = 0 + var droppedRemainderByteCount = 0 + var didCompleteSourceCancellation = false + } + + private let state = Mutex(State()) + + func beginChunk(byteCount: Int) { + state.withLock { state in + precondition( + state.currentChunkRemainderByteCount == 0, + "A new stdout chunk cannot be read before its predecessor is consumed." + ) + state.successfulReadCount += 1 + state.maximumChunkByteCount = max(state.maximumChunkByteCount, byteCount) + state.currentChunkRemainderByteCount = byteCount + } + } + + func updateRemainder(byteCount: Int) { + state.withLock { state in + precondition(byteCount >= 0) + state.currentChunkRemainderByteCount = byteCount + } + } + + func dropRemainder(byteCount: Int) { + state.withLock { state in + precondition(byteCount >= 0) + state.droppedRemainderByteCount += byteCount + state.currentChunkRemainderByteCount = 0 + } + } + + func sourceCancellationCompleted() { + state.withLock { $0.didCompleteSourceCancellation = true } + } + + func snapshot() -> AppServerStdoutReadSnapshot { + state.withLock { state in + .init( + successfulReadCount: state.successfulReadCount, + maximumChunkByteCount: state.maximumChunkByteCount, + currentChunkRemainderByteCount: state.currentChunkRemainderByteCount, + droppedRemainderByteCount: state.droppedRemainderByteCount, + sourceCancellationCompleted: state.didCompleteSourceCancellation + ) + } + } +} + +private final class AppServerProcessExitEventSource: Sendable { + private struct State { + var source: DispatchSourceProcess? + } + + private let signal = AppServerOneBitReadSignal() + private let cancellationCompletion = AppServerCancellationCompletion() + private let state: Mutex + + init(processIdentifier: pid_t) { + let queue = DispatchQueue(label: "CodexAppServerKit.app-server-process-exit") + let source = DispatchSource.makeProcessSource( + identifier: processIdentifier, + eventMask: .exit, + queue: queue + ) + self.state = Mutex(.init(source: source)) + let signal = signal + source.setEventHandler { + signal.signalReadiness() + } + let cancellationCompletion = cancellationCompletion + source.setCancelHandler { + signal.cancel() + cancellationCompletion.complete() + } + source.resume() + } + + deinit { + cancel() + } + + func next() async -> AppServerReadEvent { + await signal.next() + } + + func cancel() { + let source = state.withLock { state in + defer { state.source = nil } + return state.source + } + source?.cancel() + } + + func cancelAndWait() async { + cancel() + await cancellationCompletion.wait() + } +} + +private final class AppServerCancellationCompletion: Sendable { + private enum State { + case pending([CheckedContinuation]) + case complete + } + + private let state = Mutex(.pending([])) + + func wait() async { + await withCheckedContinuation { continuation in + let isComplete = state.withLock { state in + switch state { + case .pending(var waiters): + waiters.append(continuation) + state = .pending(waiters) + return false + case .complete: + return true + } + } + if isComplete { + continuation.resume() + } + } + } + + func complete() { + let waiters = state.withLock { state in + switch state { + case .pending(let waiters): + state = .complete + return waiters + case .complete: + return [] + } + } + for waiter in waiters { + waiter.resume() + } + } +} + +package final class AppServerJSONRPCWriter: Sendable { + private struct State { + var fileHandle: FileHandle? + var writeOverride: (@Sendable (Data) throws -> Void)? + } + + private let state: Mutex + + package init(fileHandle: FileHandle) { + self.state = Mutex(.init(fileHandle: fileHandle, writeOverride: nil)) + } + + package init( + fileHandle: FileHandle, + writeOverride: @escaping @Sendable (Data) throws -> Void + ) { + self.state = Mutex(.init( + fileHandle: fileHandle, + writeOverride: writeOverride + )) + } + + package func write(_ data: Data) throws { + try state.withLock { state in + guard let fileHandle = state.fileHandle else { + throw JSONRPC.Error.closed + } + if let writeOverride = state.writeOverride { + try writeOverride(data) + return + } + try fileHandle.write(contentsOf: data) + } + } + + package func close() { + state.withLock { state in + try? state.fileHandle?.close() + state.fileHandle = nil + state.writeOverride = nil + } + } +} + +private struct AppServerProcessLaunch { + var process: AppServerSpawnedProcess + var stdin: Pipe + var stdout: Pipe + var stderr: Pipe +} + +package struct AppServerStderrLogFilter: Sendable { + package struct Event: Equatable, Sendable { + package enum Level: Equatable, Sendable { + case error + case warning + } + + package var level: Level + package var message: String + } + + private var partialLine = "" + private var isAwaitingToolErrorOutput = false + private var suppressingCommandOutput = false + private var suppressedCommandOutputLineCount = 0 + + package init() {} + + package mutating func append(_ data: Data) -> [Event] { + guard let text = String(data: data, encoding: .utf8) else { + return [ + .init( + level: .error, + message: "emitted \(data.count) undecodable bytes" + ) + ] + } + return append(text) + } + + package mutating func append(_ text: String) -> [Event] { + guard text.isEmpty == false else { + return [] + } + + let bufferedText = partialLine + text + partialLine = "" + + var events: [Event] = [] + var lineStart = bufferedText.startIndex + var index = bufferedText.startIndex + while index < bufferedText.endIndex { + if bufferedText[index].isNewline { + let line = String(bufferedText[lineStart.. [Event] { + var events: [Event] = [] + if partialLine.isEmpty == false { + events.append(contentsOf: processLine(partialLine)) + partialLine = "" + } + events.append(contentsOf: flushSuppressedCommandOutput()) + return events + } + + private mutating func processLine(_ rawLine: String) -> [Event] { + let line = Self.stripANSIEscapeSequences(rawLine) + if suppressingCommandOutput { + if Self.isStructuredLogLine(line) { + var events = flushSuppressedCommandOutput() + events.append(contentsOf: processLine(line)) + return events + } + if Self.isTimeoutSummaryLine(line) { + return [.init(level: .warning, message: line)] + } + suppressedCommandOutputLineCount += 1 + return [] + } + + guard line.isEmpty == false else { + return [] + } + if isAwaitingToolErrorOutput, Self.isOutputStartLine(line) { + isAwaitingToolErrorOutput = false + suppressingCommandOutput = true + suppressedCommandOutputLineCount = 0 + return [.init(level: .warning, message: "command output omitted after tool error")] + } + isAwaitingToolErrorOutput = Self.canBeFollowedByCommandOutput(line) + return [.init(level: .error, message: line)] + } + + private mutating func flushSuppressedCommandOutput() -> [Event] { + guard suppressingCommandOutput else { + return [] + } + suppressingCommandOutput = false + isAwaitingToolErrorOutput = false + let lineCount = suppressedCommandOutputLineCount + suppressedCommandOutputLineCount = 0 + guard lineCount > 0 else { + return [] + } + return [.init(level: .warning, message: "suppressed \(lineCount) command-output line(s)")] + } + + private static func stripANSIEscapeSequences(_ line: String) -> String { + line.replacingOccurrences( + of: "\u{001B}\\[[0-?]*[ -/]*[@-~]", + with: "", + options: .regularExpression + ) + } + + private static func isOutputStartLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed == "Output:" || trimmed.hasSuffix(" Output:") + } + + private static func isStructuredLogLine(_ line: String) -> Bool { + line.range( + of: + #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s+(?:ERROR|WARN|INFO|DEBUG|TRACE)\s+"#, + options: .regularExpression + ) != nil + } + + private static func isTimeoutSummaryLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("command timed out after ") || trimmed.hasPrefix("Wall time: ") + || trimmed.hasPrefix("Exit code: ") + } + + private static func canBeFollowedByCommandOutput(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.contains("codex_core::tools::router: error=") + || trimmed.hasPrefix("Wall time: ") || trimmed.hasPrefix("Exit code: ") + } +} + +package struct AppServerProcessLifecycleSnapshot: Equatable, Sendable { + package var observedExitStatus: Int32? + package var didObserveExit: Bool + package var didBeginTermination: Bool + package var didReap: Bool + package var reapSystemCallCount: Int +} + +private final class AppServerSpawnedProcess: @unchecked Sendable { + let processIdentifier: pid_t + + private struct ExitState { + var observation: JSONRPC.ProcessExitObservation? + var didBeginTermination = false + var didReap = false + var reapSystemCallCount = 0 + } + private let exitState = Mutex(ExitState()) + + private init(processIdentifier: pid_t) { + self.processIdentifier = processIdentifier + } + + static func launch( + executable: String, + arguments: [String], + environment: [String: String] + ) throws -> AppServerProcessLaunch { + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + + var fileActions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + try check(posix_spawn_file_actions_init(&fileActions)) + try check(posix_spawnattr_init(&attributes)) + defer { + posix_spawn_file_actions_destroy(&fileActions) + posix_spawnattr_destroy(&attributes) + } + + try check( + posix_spawn_file_actions_adddup2( + &fileActions, + stdin.fileHandleForReading.fileDescriptor, + STDIN_FILENO + )) + try check( + posix_spawn_file_actions_adddup2( + &fileActions, + stdout.fileHandleForWriting.fileDescriptor, + STDOUT_FILENO + )) + try check( + posix_spawn_file_actions_adddup2( + &fileActions, + stderr.fileHandleForWriting.fileDescriptor, + STDERR_FILENO + )) + for fileDescriptor in AppServerProcessFileDescriptorPlan.childPipeDescriptorsToClose([ + stdin.fileHandleForReading.fileDescriptor, + stdin.fileHandleForWriting.fileDescriptor, + stdout.fileHandleForReading.fileDescriptor, + stdout.fileHandleForWriting.fileDescriptor, + stderr.fileHandleForReading.fileDescriptor, + stderr.fileHandleForWriting.fileDescriptor, + ]) { + try check(posix_spawn_file_actions_addclose(&fileActions, fileDescriptor)) + } + try check(posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP))) + try check(posix_spawnattr_setpgroup(&attributes, 0)) + + let argv = [executable] + arguments + let envp = + environment + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + + var processIdentifier = pid_t() + try executable.withCString { executablePointer in + try withCStringArray(argv) { argvPointers in + try withCStringArray(envp) { envPointers in + try check( + posix_spawn( + &processIdentifier, + executablePointer, + &fileActions, + &attributes, + argvPointers, + envPointers + )) + } + } + } + + try? stdin.fileHandleForReading.close() + try? stdout.fileHandleForWriting.close() + try? stderr.fileHandleForWriting.close() + + return .init( + process: .init(processIdentifier: processIdentifier), + stdin: stdin, + stdout: stdout, + stderr: stderr + ) + } + + func waitForExit( + terminationToken: ProcessTerminationToken, + graceDuration: Duration = .seconds(2) + ) async -> JSONRPC.ProcessExitObservation { + let exitSource = AppServerProcessExitEventSource( + processIdentifier: processIdentifier + ) + let observation: JSONRPC.ProcessExitObservation + if let alreadyExited = observeExitIfAvailable() { + if case .failed = alreadyExited { + terminationToken.terminateOnce() + await Self.ensureExitAfterWaitFailure( + exitSource: exitSource, + terminationToken: terminationToken, + graceDuration: graceDuration + ) + } + observation = alreadyExited + } else { + switch await Self.waitForExitOrTermination( + exitSource: exitSource, + terminationToken: terminationToken + ) { + case .exit: + observation = waitForExitObservationAfterReadiness() + case .termination: + if terminationToken.didRequestKill == false { + let exitedDuringGrace = await Self.waitForExitDuringGrace( + exitSource: exitSource, + graceDuration: graceDuration + ) + if exitedDuringGrace { + observation = waitForExitObservationAfterReadiness() + break + } + terminationToken.killOnce() + } + if case .ready = await exitSource.next() { + observation = waitForExitObservationAfterReadiness() + } else { + observation = .failed(.closed) + } + case .cancelled: + observation = .failed(.closed) + case .graceExpired: + preconditionFailure("Initial process wait cannot produce grace expiration.") + } + } + await exitSource.cancelAndWait() + return observation + } + + func markTerminationStarted() -> JSONRPC.ProcessExitObservation? { + exitState.withLock { state in + if state.observation == nil { + state.observation = Self.probeExit( + processIdentifier: processIdentifier, + didBeginTermination: state.didBeginTermination + ) + } + state.didBeginTermination = true + return state.observation + } + } + + func observedExitForCloseArbitration() -> JSONRPC.ProcessExitObservation? { + exitState.withLock { $0.observation } + } + + func reap() async { + reapAfterObservedExit() + } + + func lifecycleSnapshot() -> AppServerProcessLifecycleSnapshot { + exitState.withLock { state in + let status: Int32? + let didObserveExit: Bool + if case .exited(let observedStatus, _) = state.observation { + status = observedStatus + didObserveExit = true + } else { + status = nil + didObserveExit = false + } + return .init( + observedExitStatus: status, + didObserveExit: didObserveExit, + didBeginTermination: state.didBeginTermination, + didReap: state.didReap, + reapSystemCallCount: state.reapSystemCallCount + ) + } + } + + private func observeExitIfAvailable() -> JSONRPC.ProcessExitObservation? { + exitState.withLock { state in + if let observation = state.observation { + return observation + } + let observation = Self.probeExit( + processIdentifier: processIdentifier, + didBeginTermination: state.didBeginTermination + ) + state.observation = observation + return observation + } + } + + private func reapAfterObservedExit() { + exitState.withLock { state in + if state.didReap { + return + } + var rawStatus: Int32 = 0 + let result: pid_t + while true { + let current = waitpid(processIdentifier, &rawStatus, 0) + if current == -1, errno == EINTR { + continue + } + result = current + break + } + if result == processIdentifier { + state.didReap = true + state.reapSystemCallCount += 1 + if case .exited(let observedStatus, _) = state.observation { + precondition( + observedStatus == Self.exitCode(from: rawStatus), + "waitid observation and waitpid reap status must agree." + ) + } + return + } + if result == -1, errno == ECHILD { + state.didReap = true + logger.error( + "codex app-server pid \(self.processIdentifier, privacy: .public) was reaped outside its transport owner" + ) + return + } + preconditionFailure( + "waitpid failed while reaping app-server pid \(processIdentifier): \(Self.errnoMessage(errno))" + ) + } + } + + private enum WaitEvent: Equatable, Sendable { + case exit + case termination + case graceExpired + case cancelled + } + + private static func waitForExitOrTermination( + exitSource: AppServerProcessExitEventSource, + terminationToken: ProcessTerminationToken + ) async -> WaitEvent { + await withTaskGroup(of: WaitEvent.self) { group in + group.addTask { + switch await exitSource.next() { + case .ready: .exit + case .cancelled: .cancelled + } + } + group.addTask { + switch await terminationToken.nextTerminationRequest() { + case .ready: .termination + case .cancelled: .cancelled + } + } + guard let first = await group.next() else { + preconditionFailure("Process wait race requires a winner.") + } + group.cancelAll() + while await group.next() != nil {} + return first + } + } + + private static func waitForExitDuringGrace( + exitSource: AppServerProcessExitEventSource, + graceDuration: Duration + ) async -> Bool { + await withTaskGroup(of: WaitEvent.self) { group in + group.addTask { + switch await exitSource.next() { + case .ready: .exit + case .cancelled: .cancelled + } + } + group.addTask { + do { + try await Task.sleep(for: graceDuration) + return .graceExpired + } catch { + return .cancelled + } + } + guard let first = await group.next() else { + preconditionFailure("Process grace race requires a winner.") + } + group.cancelAll() + while await group.next() != nil {} + return first == .exit + } + } + + private static func ensureExitAfterWaitFailure( + exitSource: AppServerProcessExitEventSource, + terminationToken: ProcessTerminationToken, + graceDuration: Duration + ) async { + if terminationToken.didRequestKill == false { + let exitedDuringGrace = await waitForExitDuringGrace( + exitSource: exitSource, + graceDuration: graceDuration + ) + if exitedDuringGrace { + return + } + terminationToken.killOnce() + } + _ = await exitSource.next() + } + + private func waitForExitObservationAfterReadiness() -> JSONRPC.ProcessExitObservation { + exitState.withLock { state in + if let observation = state.observation { + return observation + } + let observation = Self.waitForExitObservationAfterReadiness( + processIdentifier: processIdentifier, + didBeginTermination: state.didBeginTermination + ) + state.observation = observation + return observation + } + } + + private static func waitForExitObservationAfterReadiness( + processIdentifier: pid_t, + didBeginTermination: Bool + ) -> JSONRPC.ProcessExitObservation { + var info = siginfo_t() + let result: Int32 + while true { + let current = waitid( + P_PID, + id_t(processIdentifier), + &info, + WEXITED | WNOWAIT + ) + if current == -1, errno == EINTR { + continue + } + result = current + break + } + guard result == 0, info.si_pid == processIdentifier else { + let errorNumber = result == -1 ? errno : EPROTO + return .failed(.io( + errno: errorNumber, + message: "waitid failed for ready app-server pid \(processIdentifier): \(errnoMessage(errorNumber))" + )) + } + return terminalObservation( + from: info, + didBeginTermination: didBeginTermination + ) + } + + private static func probeExit( + processIdentifier: pid_t, + didBeginTermination: Bool + ) -> JSONRPC.ProcessExitObservation? { + var info = siginfo_t() + let result: Int32 + while true { + let current = waitid( + P_PID, + id_t(processIdentifier), + &info, + WEXITED | WNOHANG | WNOWAIT + ) + if current == -1, errno == EINTR { + continue + } + result = current + break + } + if result == 0, info.si_pid == 0 { + return nil + } + if result == 0, info.si_pid == processIdentifier { + return terminalObservation( + from: info, + didBeginTermination: didBeginTermination + ) + } + let errorNumber = result == -1 ? errno : EPROTO + return .failed(.io( + errno: errorNumber, + message: "waitid failed for app-server pid \(processIdentifier): \(errnoMessage(errorNumber))" + )) + } + + private static func terminalObservation( + from info: siginfo_t, + didBeginTermination: Bool + ) -> JSONRPC.ProcessExitObservation { + let status: Int32 + switch info.si_code { + case CLD_EXITED: + status = info.si_status + case CLD_KILLED, CLD_DUMPED: + status = -info.si_status + default: + return .failed(.contractViolation( + message: "waitid returned nonterminal child status code \(info.si_code)." + )) + } + return .exited( + status: status, + observedBeforeTermination: didBeginTermination == false + ) + } + + private static func exitCode(from waitStatus: Int32) -> Int32 { + let terminationSignal = waitStatus & 0x7f + if terminationSignal == 0 { + return (waitStatus >> 8) & 0xff + } + if terminationSignal != 0x7f { + return -terminationSignal + } + return waitStatus + } + + private static func errnoMessage(_ errorNumber: Int32) -> String { + String(cString: strerror(errorNumber)) + } + + private static func check(_ result: Int32) throws { + guard result == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: result) ?? .EINVAL) + } + } + + private static func withCStringArray( + _ strings: [String], + _ body: (UnsafeMutablePointer?>?) throws -> R + ) throws -> R { + let cStrings = try strings.map { string -> UnsafeMutablePointer in + guard let pointer = strdup(string) else { + throw POSIXError(.ENOMEM) + } + return pointer + } + defer { + for pointer in cStrings { + free(pointer) + } + } + var pointers = cStrings.map(Optional.some) + pointers.append(nil) + return try pointers.withUnsafeMutableBufferPointer { buffer in + try body(buffer.baseAddress) + } + } +} + +package final class ProcessTerminationToken: Sendable { + private struct State { + var didRequestTermination = false + var didRequestKill = false + } + + private let processGroupID: pid_t? + private let state = Mutex(State()) + private let terminationRequestSignal = AppServerOneBitReadSignal() + + package init(processGroupID: pid_t? = nil) { + self.processGroupID = processGroupID + } + + package var didRequestTermination: Bool { + state.withLock { $0.didRequestTermination } + } + + package var didRequestKill: Bool { + state.withLock { $0.didRequestKill } + } + + package func terminateOnce() { + let shouldSignal = state.withLock { state in + guard state.didRequestTermination == false else { + return false + } + state.didRequestTermination = true + return true + } + if shouldSignal, let processGroupID { + _ = Darwin.kill(-processGroupID, SIGTERM) + } + if shouldSignal { + terminationRequestSignal.signalReadiness() + } + } + + package func killOnce() { + let shouldSignal = state.withLock { state in + guard state.didRequestKill == false else { + return false + } + state.didRequestKill = true + return true + } + if shouldSignal, let processGroupID { + _ = Darwin.kill(-processGroupID, SIGKILL) + } + } + + fileprivate func nextTerminationRequest() async -> AppServerReadEvent { + await terminationRequestSignal.next() + } +} + +package enum AppServerProcessFileDescriptorPlan { + package static func childPipeDescriptorsToClose(_ fileDescriptors: [Int32]) -> [Int32] { + fileDescriptors.filter { fileDescriptor in + fileDescriptor != STDIN_FILENO + && fileDescriptor != STDOUT_FILENO + && fileDescriptor != STDERR_FILENO + } + } +} + +package enum AppServerCodexHome { + package static func environment( + _ environment: [String: String], + codexHomeURL: URL + ) -> [String: String] { + var effectiveEnvironment = environment + effectiveEnvironment["CODEX_HOME"] = codexHomeURL.path + effectiveEnvironment["CODEX_SQLITE_HOME"] = sqliteHomeURL(for: codexHomeURL).path + return effectiveEnvironment + } + + package static func sqliteHomeURL(for codexHomeURL: URL) -> URL { + codexHomeURL.appendingPathComponent("sqlite", isDirectory: true) + } + + package static func ensureScaffold(at codexHomeURL: URL) throws { + try FileManager.default.createDirectory( + at: codexHomeURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: sqliteHomeURL(for: codexHomeURL), + withIntermediateDirectories: true + ) + try createEmptyFileIfMissing(at: codexHomeURL.appendingPathComponent("config.toml")) + try createEmptyFileIfMissing(at: codexHomeURL.appendingPathComponent("AGENTS.md")) + } + + private static func createEmptyFileIfMissing(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) == false else { + return + } + try Data().write(to: url) + } +} + +package enum CodexAppServerExecutable { + package struct Command { + package var executable: String + package var arguments: [String] + } + + package static let fileBackedAuthConfiguration = #"cli_auth_credentials_store="file""# + + package static func resolve(environment: [String: String] = ProcessInfo.processInfo.environment) + -> Command + { + let executable = resolveExecutable(environment: environment) + return .init( + executable: executable, + arguments: appServerArguments() + ) + } + + package static func resolveExecutable( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + let requestedCommand = + [ + environment["CODEX_APP_SERVER_CODEX_EXECUTABLE"], + environment["CODEX_REVIEW_CODEX_EXECUTABLE"], + environment["CODEX_EXECUTABLE"], + ].compactMap(\.self).first ?? "codex" + + return resolveExecutable(requestedCommand, environment: environment) + } + + package static func resolveExecutable( + _ requestedCommand: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + if let candidate = findExecutable( + requestedCommand, + environment: environment + ) { + return candidate + } + + return requestedCommand + } + + package static func appServerArguments() -> [String] { + [ + "-c", fileBackedAuthConfiguration, + "app-server", + "--listen", "stdio://", + ] + } + + private static func findExecutable( + _ requestedCommand: String, + environment: [String: String] + ) -> String? { + let trimmedCommand = requestedCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedCommand.isEmpty == false else { + return nil + } + if trimmedCommand.contains("/") { + return FileManager.default.isExecutableFile(atPath: trimmedCommand) + ? trimmedCommand : nil + } + for directory in pathSearchDirectories(environment: environment) { + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent(trimmedCommand) + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + package static func pathSearchDirectories(environment: [String: String]) -> [String] { + let environmentDirectories = (environment["PATH"] ?? "") + .split(separator: ":", omittingEmptySubsequences: true) + .map(String.init) + var knownDirectories: [String] = [] + if let homeDirectory = environment["HOME"]?.trimmingCharacters( + in: .whitespacesAndNewlines + ), + homeDirectory.isEmpty == false { + // The standalone Codex installer defaults here even when a GUI app's PATH omits it. + knownDirectories.append( + URL(fileURLWithPath: homeDirectory, isDirectory: true) + .appendingPathComponent(".local/bin", isDirectory: true) + .path + ) + } + knownDirectories += [ + "/Applications/Codex.app/Contents/Resources", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + var directories: [String] = [] + for directory in environmentDirectories + knownDirectories + where directories.contains(directory) == false { + directories.append(directory) + } + return directories + } +} + +private func makeRequestPayload(_ request: JSONRPC.Request) throws -> Data { + let params = try JSONSerialization.jsonObject(with: request.params) + let object: [String: Any] = [ + "id": request.id, + "method": request.method, + "params": params, + ] + var data = try JSONSerialization.data(withJSONObject: object) + data.append(0x0A) + return data +} + +private func makeNotificationPayload(_ notification: JSONRPC.Notification) throws -> Data { + let params = try JSONSerialization.jsonObject(with: notification.params) + let object: [String: Any] = [ + "method": notification.method, + "params": params, + ] + var data = try JSONSerialization.data(withJSONObject: object) + data.append(0x0A) + return data +} diff --git a/Sources/CodexAppServerKit/AppServerRequests.swift b/Sources/CodexAppServerKit/AppServerRequests.swift new file mode 100644 index 00000000..ad49efc1 --- /dev/null +++ b/Sources/CodexAppServerKit/AppServerRequests.swift @@ -0,0 +1,2808 @@ +import Foundation + +package enum AppServerAPI { + package enum Initialize {} + package enum Thread { + package enum Start {} + package enum Resume {} + package enum Fork {} + package enum List {} + package enum Read {} + package enum Turns { + package enum List {} + } + package enum Archive {} + package enum Unarchive {} + package enum Name { + package enum Set {} + } + package enum Compact { + package enum Start {} + } + package enum Rollback {} + package enum Delete {} + package enum Unsubscribe {} + package enum BackgroundTerminals { + package enum Clean {} + } + } + package enum Review { + package enum Start {} + } + package enum Turn { + package enum Start {} + package enum Steer {} + package enum Interrupt {} + } + package enum Config { + package enum Read {} + package enum BatchWrite {} + } + package enum Model { + package enum List {} + } + package enum Auth { + package enum Read {} + } + package enum Account { + package enum Read {} + package enum Logout {} + package enum RateLimits { + package enum Read {} + } + package enum Login { + package enum Start {} + package enum Cancel {} + } + } +} + +extension AppServerAPI { + package enum RequestScope: Hashable, Sendable { + case thread(String) + } +} + +extension AppServerAPI.Review.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var target: CodexReviewTarget + package var delivery: CodexReviewDelivery + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case target + case delivery + } + + package init( + threadID: String, + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline + ) { + self.threadID = threadID + self.target = target + self.delivery = delivery + } + } +} + +extension AppServerAPI.Review.Start { + package struct Response: Codable, Equatable, Sendable { + package var turn: AppServerAPI.Turn.Payload + package var reviewThreadID: String + package var turnID: String { + turn.id + } + + enum CodingKeys: String, CodingKey { + case turn + case reviewThreadID = "reviewThreadId" + } + + package init(turnID: String, reviewThreadID: String) { + self.init( + turn: AppServerAPI.Turn.Payload(id: turnID, status: "inProgress"), + reviewThreadID: reviewThreadID + ) + } + + package init( + turn: AppServerAPI.Turn.Payload, + reviewThreadID: String + ) { + self.turn = turn + self.reviewThreadID = reviewThreadID + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.turn = try container.decode(AppServerAPI.Turn.Payload.self, forKey: .turn) + self.reviewThreadID = try container.decode( + String.self, forKey: .reviewThreadID) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(turn, forKey: .turn) + try container.encode(reviewThreadID, forKey: .reviewThreadID) + } + } +} + +extension AppServerAPI.Review.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Review.Start.Response + + package static let method = "review/start" + package var params: AppServerAPI.Review.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Review.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI { + package protocol Request: Sendable { + associatedtype Params: Encodable & Sendable + associatedtype Response: Decodable & Sendable + + static var method: String { get } + var params: Params { get } + var scope: AppServerAPI.RequestScope? { get } + } +} + +extension AppServerAPI.Request { + package var scope: AppServerAPI.RequestScope? { nil } +} + +package enum AppServerJSONValue: Codable, Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AppServerJSONValue]) + case object([String: AppServerJSONValue]) + case null + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([AppServerJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: AppServerJSONValue].self)) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +extension AppServerAPI.Initialize { + package struct ClientInfo: Codable, Equatable, Sendable { + package var name: String + package var title: String? + package var version: String + + package init(name: String, title: String? = nil, version: String) { + self.name = name + self.title = title + self.version = version + } + } +} + +extension AppServerAPI.Initialize { + package struct Capabilities: Codable, Equatable, Sendable { + package var experimentalAPI: Bool + + enum CodingKeys: String, CodingKey { + case experimentalAPI = "experimentalApi" + } + + package init(experimentalAPI: Bool = true) { + self.experimentalAPI = experimentalAPI + } + } +} + +extension AppServerAPI.Initialize { + package struct Params: Codable, Equatable, Sendable { + package var clientInfo: AppServerAPI.Initialize.ClientInfo + package var capabilities: AppServerAPI.Initialize.Capabilities + + enum CodingKeys: String, CodingKey { + case clientInfo + case capabilities + } + + package init(clientName: String, clientVersion: String) { + self.clientInfo = .init(name: clientName, version: clientVersion) + self.capabilities = .init() + } + } +} + +extension AppServerAPI.Initialize { + package struct Response: Codable, Equatable, Sendable { + package var codexHome: String? + package var userAgent: String? + + package init(codexHome: String? = nil, userAgent: String? = nil) { + self.codexHome = codexHome + self.userAgent = userAgent + } + } +} + +extension AppServerAPI.Initialize { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Initialize.Response + + package static let method = "initialize" + package var params: AppServerAPI.Initialize.Params + + package init(params: AppServerAPI.Initialize.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String? + package var cwd: String? + package var model: String? + package var modelProvider: String? + package var ephemeral: Bool? + package var baseInstructions: String? + package var developerInstructions: String? + package var approvalPolicy: String? + package var approvalsReviewer: String? + package var sandbox: String? + package var serviceName: String? + package var serviceTier: String? + package var personality: String? + package var config: [String: AppServerJSONValue]? + package var permissions: AppServerAPI.Thread.Start.Permissions? + // Session start source drives lifecycle hooks; thread source is analytics classification. + package var sessionStartSource: AppServerAPI.Thread.Start.Source? + package var threadSource: AppServerAPI.Thread.Source? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case cwd + case model + case modelProvider + case ephemeral + case baseInstructions + case developerInstructions + case approvalPolicy + case approvalsReviewer + case sandbox + case serviceName + case serviceTier + case personality + case config + case permissions + case sessionStartSource + case threadSource + } + + package init( + threadID: String? = nil, + cwd: String? = nil, + model: String? = nil, + modelProvider: String? = nil, + ephemeral: Bool? = nil, + baseInstructions: String? = nil, + developerInstructions: String? = nil, + approvalPolicy: String? = nil, + approvalsReviewer: String? = nil, + sandbox: String? = nil, + serviceName: String? = nil, + serviceTier: String? = nil, + personality: String? = nil, + config: [String: AppServerJSONValue]? = nil, + permissions: AppServerAPI.Thread.Start.Permissions? = nil, + sessionStartSource: AppServerAPI.Thread.Start.Source? = nil, + threadSource: AppServerAPI.Thread.Source? = nil + ) { + self.threadID = threadID + self.cwd = cwd + self.model = model + self.modelProvider = modelProvider + self.ephemeral = ephemeral + self.baseInstructions = baseInstructions + self.developerInstructions = developerInstructions + self.approvalPolicy = approvalPolicy + self.approvalsReviewer = approvalsReviewer + self.sandbox = sandbox + self.serviceName = serviceName + self.serviceTier = serviceTier + self.personality = personality + self.config = config + self.permissions = permissions + self.sessionStartSource = sessionStartSource + self.threadSource = threadSource + } + } +} + +extension AppServerAPI.Thread.Start { + package enum Source: String, Codable, Equatable, Sendable { + case startup + case clear + } +} + +extension AppServerAPI.Thread { + package struct Source: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + package var rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: String) { + self.rawValue = value + } + + package static let user = Self(rawValue: "user") + package static let subagent = Self(rawValue: "subagent") + package static let memoryConsolidation = Self(rawValue: "memory_consolidation") + } +} + +extension AppServerAPI.Thread.Start { + package enum Permissions: Codable, Equatable, Sendable { + case profileID(String) + case profileSelection(AppServerAPI.Thread.Start.PermissionProfileSelection) + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let profileID = try? container.decode(String.self) { + self = .profileID(profileID) + return + } + if let profileSelection = try? container.decode( + AppServerAPI.Thread.Start.PermissionProfileSelection.self) + { + self = .profileSelection(profileSelection) + return + } + throw DecodingError.typeMismatch( + AppServerAPI.Thread.Start.Permissions.self, + .init( + codingPath: decoder.codingPath, + debugDescription: + "Expected a permissions profile ID or profile selection object." + ) + ) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .profileID(let profileID): + try container.encode(profileID) + case .profileSelection(let profileSelection): + try container.encode(profileSelection) + } + } + } +} + +extension AppServerAPI.Thread.Start { + package struct PermissionProfileSelection: Codable, Equatable, Sendable { + package var type: String + package var id: String + + package init(id: String, type: String = "profile") { + self.type = type + self.id = id + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Response: Codable, Equatable, Sendable { + package var threadID: String + package var model: String? + + enum CodingKeys: String, CodingKey { + case thread + case model + } + + private struct Thread: Codable, Equatable, Sendable { + var id: String + } + + package init(threadID: String, model: String? = nil) { + self.threadID = threadID + self.model = model + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.threadID = try container.decode(Thread.self, forKey: .thread).id + self.model = try container.decodeIfPresent(String.self, forKey: .model) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Thread(id: threadID), forKey: .thread) + try container.encodeIfPresent(model, forKey: .model) + } + } +} + +extension AppServerAPI.Thread.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Start.Response + + package static let method = "thread/start" + package var params: AppServerAPI.Thread.Start.Params + + package init(params: AppServerAPI.Thread.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread { + package enum SessionSource: Equatable, Sendable { + package enum SubAgent: Equatable, Sendable { + package struct ThreadSpawn: Codable, Equatable, Sendable { + package var parentThreadID: String + package var depth: Int + package var agentPath: String? + package var agentNickname: String? + package var agentRole: String? + + enum CodingKeys: String, CodingKey { + case parentThreadID = "parent_thread_id" + case depth + case agentPath = "agent_path" + case agentNickname = "agent_nickname" + case agentRole = "agent_role" + } + + package init( + parentThreadID: String, + depth: Int, + agentPath: String? = nil, + agentNickname: String? = nil, + agentRole: String? = nil + ) { + self.parentThreadID = parentThreadID + self.depth = depth + self.agentPath = agentPath + self.agentNickname = agentNickname + self.agentRole = agentRole + } + } + + case review + case compact + case threadSpawn(ThreadSpawn) + case memoryConsolidation + case other(String) + } + + case cli + case vscode + case exec + case appServer + case custom(String) + case subAgent(SubAgent) + case unknown + + package var sourceKind: CodexThreadSourceKind? { + switch self { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .custom: + nil + case .unknown: + .unknown + case .subAgent(.review): + .subAgentReview + case .subAgent(.compact): + .subAgentCompact + case .subAgent(.threadSpawn): + .subAgentThreadSpawn + case .subAgent(.memoryConsolidation): + .subAgent + case .subAgent(.other): + .subAgentOther + } + } + } + + package struct Snapshot: Codable, Equatable, Sendable { + package enum Field: String, Hashable, Sendable { + case sessionID + case parentThreadID + case cwd + case name + case preview + case modelProvider + case source + case gitInfo + case createdAt + case updatedAt + case recencyAt + case status + case ephemeral + case turns + } + + package struct Status: Codable, Equatable, Sendable { + package var type: String + package var activeFlags: [String]? + + package init(type: String, activeFlags: [String]? = nil) { + self.type = type + self.activeFlags = activeFlags + } + } + + package struct GitInfo: Codable, Equatable, Sendable { + package var sha: String? + package var branch: String? + package var originURL: String? + + enum CodingKeys: String, CodingKey { + case sha + case branch + case originURL = "originUrl" + } + + package init( + sha: String? = nil, + branch: String? = nil, + originURL: String? = nil + ) { + self.sha = sha + self.branch = branch + self.originURL = originURL + } + } + + package var id: String + package var sessionID: String? + package var parentThreadID: String? + package var cwd: String? + package var name: String? + package var preview: String? + package var modelProvider: String? + package var source: AppServerAPI.Thread.SessionSource? + package var gitInfo: GitInfo? + package var createdAt: Int? + package var updatedAt: Int? + package var recencyAt: Int? + package var status: Status? + package var ephemeral: Bool? + package var turns: [AppServerAPI.Turn.Payload]? + package var presentFields: Set + + package var sourceKind: CodexThreadSourceKind? { + source?.sourceKind + } + + enum CodingKeys: String, CodingKey { + case id + case sessionID = "sessionId" + case parentThreadID = "parentThreadId" + case cwd + case name + case preview + case modelProvider + case source + case gitInfo + case createdAt + case updatedAt + case recencyAt + case status + case ephemeral + case turns + } + + package init( + id: String, + sessionID: String? = nil, + parentThreadID: String? = nil, + cwd: String? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + source: AppServerAPI.Thread.SessionSource? = nil, + gitInfo: GitInfo? = nil, + createdAt: Int? = nil, + updatedAt: Int? = nil, + recencyAt: Int? = nil, + status: Status? = nil, + ephemeral: Bool? = nil, + turns: [AppServerAPI.Turn.Payload]? = nil, + presentFields: Set? = nil + ) { + self.id = id + self.sessionID = sessionID + self.parentThreadID = parentThreadID + self.cwd = cwd + self.name = name + self.preview = preview + self.modelProvider = modelProvider + self.source = source + self.gitInfo = gitInfo + self.createdAt = createdAt + self.updatedAt = updatedAt + self.recencyAt = recencyAt + self.status = status + self.ephemeral = ephemeral + self.turns = turns + self.presentFields = presentFields ?? Self.presentFields( + sessionID: sessionID, + parentThreadID: parentThreadID, + cwd: cwd, + name: name, + preview: preview, + modelProvider: modelProvider, + source: source, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns + ) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID) + parentThreadID = try container.decodeIfPresent(String.self, forKey: .parentThreadID) + cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + name = try container.decodeIfPresent(String.self, forKey: .name) + preview = try container.decodeIfPresent(String.self, forKey: .preview) + modelProvider = try container.decodeIfPresent(String.self, forKey: .modelProvider) + source = try container.decodeIfPresent( + AppServerAPI.Thread.SessionSource.self, + forKey: .source + ) + gitInfo = try container.decodeIfPresent(GitInfo.self, forKey: .gitInfo) + createdAt = try container.decodeIfPresent(Int.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Int.self, forKey: .updatedAt) + recencyAt = try container.decodeIfPresent(Int.self, forKey: .recencyAt) + status = try container.decodeIfPresent(Status.self, forKey: .status) + ephemeral = try container.decodeIfPresent(Bool.self, forKey: .ephemeral) + turns = try container.decodeIfPresent([AppServerAPI.Turn.Payload].self, forKey: .turns) + presentFields = Self.presentFields(from: container) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try encode(sessionID, forKey: .sessionID, into: &container) + try encode(parentThreadID, forKey: .parentThreadID, into: &container) + try encode(cwd, forKey: .cwd, into: &container) + try encode(name, forKey: .name, into: &container) + try encode(preview, forKey: .preview, into: &container) + try encode(modelProvider, forKey: .modelProvider, into: &container) + try encode(source, forKey: .source, into: &container) + try encode(gitInfo, forKey: .gitInfo, into: &container) + try encode(createdAt, forKey: .createdAt, into: &container) + try encode(updatedAt, forKey: .updatedAt, into: &container) + try encode(recencyAt, forKey: .recencyAt, into: &container) + try encode(status, forKey: .status, into: &container) + try encode(ephemeral, forKey: .ephemeral, into: &container) + try encode(turns, forKey: .turns, into: &container) + } + + private func encode( + _ value: Value?, + forKey key: CodingKeys, + into container: inout KeyedEncodingContainer + ) throws { + guard let field = Field(key), presentFields.contains(field) else { + return + } + if let value { + try container.encode(value, forKey: key) + } else { + try container.encodeNil(forKey: key) + } + } + + private static func presentFields( + sessionID: String?, + parentThreadID: String?, + cwd: String?, + name: String?, + preview: String?, + modelProvider: String?, + source: AppServerAPI.Thread.SessionSource?, + gitInfo: GitInfo?, + createdAt: Int?, + updatedAt: Int?, + recencyAt: Int?, + status: AppServerAPI.Thread.Snapshot.Status?, + ephemeral: Bool?, + turns: [AppServerAPI.Turn.Payload]? + ) -> Set { + var fields: Set = [] + if sessionID != nil { + fields.insert(.sessionID) + } + if parentThreadID != nil { + fields.insert(.parentThreadID) + } + if cwd != nil { + fields.insert(.cwd) + } + if name != nil { + fields.insert(.name) + } + if preview != nil { + fields.insert(.preview) + } + if modelProvider != nil { + fields.insert(.modelProvider) + } + if source != nil { + fields.insert(.source) + } + if gitInfo != nil { + fields.insert(.gitInfo) + } + if createdAt != nil { + fields.insert(.createdAt) + } + if updatedAt != nil { + fields.insert(.updatedAt) + } + if recencyAt != nil { + fields.insert(.recencyAt) + } + if status != nil { + fields.insert(.status) + } + if ephemeral != nil { + fields.insert(.ephemeral) + } + if turns != nil { + fields.insert(.turns) + } + return fields + } + + private static func presentFields( + from container: KeyedDecodingContainer + ) -> Set { + var fields: Set = [] + for key in container.allKeys { + if let field = Field(key) { + fields.insert(field) + } + } + return fields + } + } +} + +extension AppServerAPI.Thread.SessionSource: Codable { + private enum CodingKeys: String, CodingKey { + case custom + case subAgent + } + + package init(from decoder: Decoder) throws { + if let value = try? decoder.singleValueContainer().decode(String.self) { + switch value { + case "cli": + self = .cli + case "vscode": + self = .vscode + case "exec": + self = .exec + case "appServer": + self = .appServer + case "unknown": + self = .unknown + default: + self = .unknown + } + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.custom) { + self = .custom(try container.decode(String.self, forKey: .custom)) + return + } + if container.contains(.subAgent) { + self = .subAgent(try container.decode(SubAgent.self, forKey: .subAgent)) + return + } + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported current-v2 thread session source." + ) + ) + } + + package func encode(to encoder: Encoder) throws { + switch self { + case .cli: + var container = encoder.singleValueContainer() + try container.encode("cli") + case .vscode: + var container = encoder.singleValueContainer() + try container.encode("vscode") + case .exec: + var container = encoder.singleValueContainer() + try container.encode("exec") + case .appServer: + var container = encoder.singleValueContainer() + try container.encode("appServer") + case .unknown: + var container = encoder.singleValueContainer() + try container.encode("unknown") + case .custom(let value): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .custom) + case .subAgent(let source): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(source, forKey: .subAgent) + } + } +} + +extension AppServerAPI.Thread.SessionSource.SubAgent: Codable { + private enum CodingKeys: String, CodingKey { + case threadSpawn = "thread_spawn" + case other + } + + package init(from decoder: Decoder) throws { + if let value = try? decoder.singleValueContainer().decode(String.self) { + switch value { + case "review": + self = .review + case "compact": + self = .compact + case "memory_consolidation": + self = .memoryConsolidation + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported current-v2 sub-agent source \(value)." + ) + ) + } + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.threadSpawn) { + self = .threadSpawn(try container.decode(ThreadSpawn.self, forKey: .threadSpawn)) + return + } + if container.contains(.other) { + self = .other(try container.decode(String.self, forKey: .other)) + return + } + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported current-v2 sub-agent source." + ) + ) + } + + package func encode(to encoder: Encoder) throws { + switch self { + case .review: + var container = encoder.singleValueContainer() + try container.encode("review") + case .compact: + var container = encoder.singleValueContainer() + try container.encode("compact") + case .memoryConsolidation: + var container = encoder.singleValueContainer() + try container.encode("memory_consolidation") + case .threadSpawn(let source): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(source, forKey: .threadSpawn) + case .other(let value): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .other) + } + } +} + +private extension AppServerAPI.Thread.Snapshot.Field { + init?(_ key: AppServerAPI.Thread.Snapshot.CodingKeys) { + switch key { + case .id: + return nil + case .sessionID: + self = .sessionID + case .parentThreadID: + self = .parentThreadID + case .cwd: + self = .cwd + case .name: + self = .name + case .preview: + self = .preview + case .modelProvider: + self = .modelProvider + case .source: + self = .source + case .gitInfo: + self = .gitInfo + case .createdAt: + self = .createdAt + case .updatedAt: + self = .updatedAt + case .recencyAt: + self = .recencyAt + case .status: + self = .status + case .ephemeral: + self = .ephemeral + case .turns: + self = .turns + } + } +} + +extension AppServerAPI.Thread.Resume { + package typealias Params = AppServerAPI.Thread.Start.Params + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + package var model: String? + + package init(thread: AppServerAPI.Thread.Snapshot, model: String? = nil) { + self.thread = thread + self.model = model + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Resume.Response + + package static let method = "thread/resume" + package var params: AppServerAPI.Thread.Resume.Params + package var scope: AppServerAPI.RequestScope? { + params.threadID.map(AppServerAPI.RequestScope.thread) + } + + package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { + var scopedParams = params + scopedParams.threadID = threadID + self.params = scopedParams + } + } +} + +extension AppServerAPI.Thread.Fork { + package typealias Params = AppServerAPI.Thread.Start.Params + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Fork.Response + + package static let method = "thread/fork" + package var params: AppServerAPI.Thread.Fork.Params + package var scope: AppServerAPI.RequestScope? { + params.threadID.map(AppServerAPI.RequestScope.thread) + } + + package init(threadID: String, params: AppServerAPI.Thread.Start.Params = .init()) { + var scopedParams = params + scopedParams.threadID = threadID + self.params = scopedParams + } + } +} + +extension AppServerAPI.Turn { + package struct Payload: Codable, Equatable, Sendable { + package var id: String + package var status: String + package var error: AppServerAPI.Turn.Error? + package var startedAt: Int? + package var completedAt: Int? + package var durationMS: Int? + package var itemsLoadState: CodexTurnItemsLoadState? + package var items: [AppServerJSONValue]? + + enum CodingKeys: String, CodingKey { + case id + case status + case error + case startedAt + case completedAt + case durationMS = "durationMs" + case itemsLoadState = "itemsView" + case items + } + + package init( + id: String, + status: String, + error: AppServerAPI.Turn.Error? = nil, + startedAt: Int? = nil, + completedAt: Int? = nil, + durationMS: Int? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil, + items: [AppServerJSONValue]? = nil + ) { + self.id = id + self.status = status + self.error = error + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMS = durationMS + self.itemsLoadState = itemsLoadState + self.items = items + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + status = try container.decode(String.self, forKey: .status) + error = try container.decodeIfPresent(AppServerAPI.Turn.Error.self, forKey: .error) + startedAt = try container.decodeIfPresent(Int.self, forKey: .startedAt) + completedAt = try container.decodeIfPresent(Int.self, forKey: .completedAt) + durationMS = try container.decodeIfPresent(Int.self, forKey: .durationMS) + itemsLoadState = try container.decodeIfPresent( + CodexTurnItemsLoadState.self, + forKey: .itemsLoadState + ) + items = try container.decodeIfPresent([AppServerJSONValue].self, forKey: .items) + + switch CodexTurnStatus(rawValue: status) { + case .failed: + guard error != nil else { + throw DecodingError.dataCorruptedError( + forKey: .error, + in: container, + debugDescription: "A failed turn requires an error payload." + ) + } + case .inProgress, .completed, .interrupted: + guard error == nil else { + throw DecodingError.dataCorruptedError( + forKey: .error, + in: container, + debugDescription: "Known non-failed turn status \(status) cannot carry an error." + ) + } + case .unknown: + break + } + } + } +} + +extension AppServerAPI.Thread.List { + package struct Params: Codable, Equatable, Sendable { + package var archived: Bool? + package var cursor: String? + package var cwd: AppServerAPI.Thread.List.CWDFilter? + package var limit: Int? + package var modelProviders: [String]? + package var searchTerm: String? + package var sortDirection: String? + package var sortKey: String? + package var sourceKinds: [String]? + package var useStateDbOnly: Bool? + + package init( + archived: Bool? = nil, + cursor: String? = nil, + cwd: AppServerAPI.Thread.List.CWDFilter? = nil, + limit: Int? = nil, + modelProviders: [String]? = nil, + searchTerm: String? = nil, + sortDirection: String? = nil, + sortKey: String? = nil, + sourceKinds: [String]? = nil, + useStateDbOnly: Bool? = nil + ) { + self.archived = archived + self.cursor = cursor + self.cwd = cwd + self.limit = limit + self.modelProviders = modelProviders + self.searchTerm = searchTerm + self.sortDirection = sortDirection + self.sortKey = sortKey + self.sourceKinds = sourceKinds + self.useStateDbOnly = useStateDbOnly + } + } + + package enum CWDFilter: Codable, Equatable, Sendable { + case paths([String]) + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self = .paths(try container.decode([String].self)) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .paths(let paths): + try container.encode(paths) + } + } + } + + package struct Response: Codable, Equatable, Sendable { + package var data: [AppServerAPI.Thread.Snapshot] + package var nextCursor: String? + package var backwardsCursor: String? + + package init( + data: [AppServerAPI.Thread.Snapshot], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.data = data + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.List.Response + + package static let method = "thread/list" + package var params: AppServerAPI.Thread.List.Params + + package init(params: AppServerAPI.Thread.List.Params = .init()) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Read { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var includeTurns: Bool? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case includeTurns + } + + package init(threadID: String, includeTurns: Bool? = nil) { + self.threadID = threadID + self.includeTurns = includeTurns + } + } + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Read.Response + + package static let method = "thread/read" + package var params: AppServerAPI.Thread.Read.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Read.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Turns.List { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var cursor: String? + package var limit: Int? + package var sortDirection: CodexSortDirection? + package var itemsLoadState: CodexTurnItemsLoadState? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case cursor + case limit + case sortDirection + case itemsLoadState = "itemsView" + } + + package init( + threadID: String, + cursor: String? = nil, + limit: Int? = nil, + sortDirection: CodexSortDirection? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil + ) { + self.threadID = threadID + self.cursor = cursor + self.limit = limit + self.sortDirection = sortDirection + self.itemsLoadState = itemsLoadState + } + } + + package struct Response: Codable, Equatable, Sendable { + package var data: [AppServerAPI.Turn.Payload] + package var nextCursor: String? + package var backwardsCursor: String? + + package init( + data: [AppServerAPI.Turn.Payload], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.data = data + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Turns.List.Response + + package static let method = "thread/turns/list" + package var params: AppServerAPI.Thread.Turns.List.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Turns.List.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Archive { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Archive.Response + + package static let method = "thread/archive" + package var params: AppServerAPI.Thread.Archive.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Archive.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Unarchive { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Response: Codable, Equatable, Sendable { + package var thread: AppServerAPI.Thread.Snapshot + + package init(thread: AppServerAPI.Thread.Snapshot) { + self.thread = thread + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Unarchive.Response + + package static let method = "thread/unarchive" + package var params: AppServerAPI.Thread.Unarchive.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Unarchive.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Name.Set { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var name: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case name + } + + package init(threadID: String, name: String) { + self.threadID = threadID + self.name = name + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Name.Set.Response + + package static let method = "thread/name/set" + package var params: AppServerAPI.Thread.Name.Set.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Name.Set.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Compact.Start { + package typealias Response = EmptyResponse + + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Compact.Start.Response + + package static let method = "thread/compact/start" + package var params: AppServerAPI.Thread.Compact.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Compact.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn { + package struct Error: Codable, Equatable, Sendable { + package var message: String + package var codexErrorInfo: AppServerAPI.CodexErrorInfo? + package var additionalDetails: String? + + package init( + message: String, + codexErrorInfo: AppServerAPI.CodexErrorInfo? = nil, + additionalDetails: String? = nil + ) { + self.message = message + self.codexErrorInfo = codexErrorInfo + self.additionalDetails = additionalDetails + } + } +} + +extension AppServerAPI { + package enum CodexErrorInfo: Codable, Equatable, Sendable { + case contextWindowExceeded + case sessionBudgetExceeded + case usageLimitExceeded + case serverOverloaded + case cyberPolicy + case httpConnectionFailed(httpStatusCode: UInt16?) + case responseStreamConnectionFailed(httpStatusCode: UInt16?) + case internalServerError + case unauthorized + case badRequest + case threadRollbackFailed + case sandboxError + case responseStreamDisconnected(httpStatusCode: UInt16?) + case responseTooManyFailedAttempts(httpStatusCode: UInt16?) + case activeTurnNotSteerable(turnKind: String) + case other + case unknown(rawValue: String) + + package init(from decoder: Decoder) throws { + if let value = try? decoder.singleValueContainer().decode(String.self) { + self = Self(simpleRawValue: value) + return + } + let object = try decoder.singleValueContainer().decode([String: Payload].self) + guard object.count == 1, let (key, payload) = object.first else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Invalid codexErrorInfo payload.") + ) + } + switch key { + case "httpConnectionFailed": + self = .httpConnectionFailed(httpStatusCode: payload.httpStatusCode) + case "responseStreamConnectionFailed": + self = .responseStreamConnectionFailed(httpStatusCode: payload.httpStatusCode) + case "responseStreamDisconnected": + self = .responseStreamDisconnected(httpStatusCode: payload.httpStatusCode) + case "responseTooManyFailedAttempts": + self = .responseTooManyFailedAttempts(httpStatusCode: payload.httpStatusCode) + case "activeTurnNotSteerable": + guard let turnKind = payload.turnKind else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Missing turnKind.") + ) + } + self = .activeTurnNotSteerable(turnKind: turnKind) + default: + self = .unknown(rawValue: key) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .httpConnectionFailed(let code): + try container.encode(["httpConnectionFailed": Payload(httpStatusCode: code)]) + case .responseStreamConnectionFailed(let code): + try container.encode(["responseStreamConnectionFailed": Payload(httpStatusCode: code)]) + case .responseStreamDisconnected(let code): + try container.encode(["responseStreamDisconnected": Payload(httpStatusCode: code)]) + case .responseTooManyFailedAttempts(let code): + try container.encode(["responseTooManyFailedAttempts": Payload(httpStatusCode: code)]) + case .activeTurnNotSteerable(let turnKind): + try container.encode(["activeTurnNotSteerable": Payload(turnKind: turnKind)]) + default: + try container.encode(simpleRawValue) + } + } + + private struct Payload: Codable, Equatable, Sendable { + var httpStatusCode: UInt16? + var turnKind: String? + + init(httpStatusCode: UInt16? = nil, turnKind: String? = nil) { + self.httpStatusCode = httpStatusCode + self.turnKind = turnKind + } + } + + private init(simpleRawValue: String) { + switch simpleRawValue { + case "contextWindowExceeded": self = .contextWindowExceeded + case "sessionBudgetExceeded": self = .sessionBudgetExceeded + case "usageLimitExceeded": self = .usageLimitExceeded + case "serverOverloaded": self = .serverOverloaded + case "cyberPolicy": self = .cyberPolicy + case "internalServerError": self = .internalServerError + case "unauthorized": self = .unauthorized + case "badRequest": self = .badRequest + case "threadRollbackFailed": self = .threadRollbackFailed + case "sandboxError": self = .sandboxError + case "other": self = .other + default: self = .unknown(rawValue: simpleRawValue) + } + } + + private var simpleRawValue: String { + switch self { + case .contextWindowExceeded: "contextWindowExceeded" + case .sessionBudgetExceeded: "sessionBudgetExceeded" + case .usageLimitExceeded: "usageLimitExceeded" + case .serverOverloaded: "serverOverloaded" + case .cyberPolicy: "cyberPolicy" + case .internalServerError: "internalServerError" + case .unauthorized: "unauthorized" + case .badRequest: "badRequest" + case .threadRollbackFailed: "threadRollbackFailed" + case .sandboxError: "sandboxError" + case .other: "other" + case .unknown(let rawValue): rawValue + case .httpConnectionFailed, .responseStreamConnectionFailed, + .responseStreamDisconnected, .responseTooManyFailedAttempts, + .activeTurnNotSteerable: + preconditionFailure("Associated codexErrorInfo has no simple raw value.") + } + } + } +} + +extension AppServerAPI { + package enum UserInput: Codable, Equatable, Sendable { + case text(String) + case image(url: String) + case localImage(path: String) + case skill(name: String, path: String) + case mention(name: String, path: String) + + private enum CodingKeys: String, CodingKey { + case type + case text + case url + case path + case name + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "text": + self = .text(try container.decode(String.self, forKey: .text)) + case "image": + self = .image(url: try container.decode(String.self, forKey: .url)) + case "localImage": + self = .localImage(path: try container.decode(String.self, forKey: .path)) + case "skill": + self = .skill( + name: try container.decode(String.self, forKey: .name), + path: try container.decode(String.self, forKey: .path) + ) + case "mention": + self = .mention( + name: try container.decode(String.self, forKey: .name), + path: try container.decode(String.self, forKey: .path) + ) + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported app-server input type: \(type)" + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let url): + try container.encode("image", forKey: .type) + try container.encode(url, forKey: .url) + case .localImage(let path): + try container.encode("localImage", forKey: .type) + try container.encode(path, forKey: .path) + case .skill(let name, let path): + try container.encode("skill", forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + case .mention(let name, let path): + try container.encode("mention", forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(path, forKey: .path) + } + } + } +} + +extension AppServerAPI.Turn { + package enum SandboxPolicy: Codable, Equatable, Sendable { + case readOnly(networkAccess: Bool) + case workspaceWrite( + writableRoots: [String], + networkAccess: Bool, + excludeTmpdirEnvVar: Bool, + excludeSlashTmp: Bool + ) + case dangerFullAccess + + private enum CodingKeys: String, CodingKey { + case type + case writableRoots + case networkAccess + case excludeTmpdirEnvVar + case excludeSlashTmp + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "readOnly": + self = .readOnly( + networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) + ?? false) + case "workspaceWrite": + self = .workspaceWrite( + writableRoots: try container.decodeIfPresent( + [String].self, forKey: .writableRoots) ?? [], + networkAccess: try container.decodeIfPresent(Bool.self, forKey: .networkAccess) + ?? false, + excludeTmpdirEnvVar: try container.decodeIfPresent( + Bool.self, forKey: .excludeTmpdirEnvVar) ?? false, + excludeSlashTmp: try container.decodeIfPresent( + Bool.self, forKey: .excludeSlashTmp) ?? false + ) + case "dangerFullAccess": + self = .dangerFullAccess + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported sandbox policy type: \(type)" + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .readOnly(let networkAccess): + try container.encode("readOnly", forKey: .type) + try container.encode(networkAccess, forKey: .networkAccess) + case .workspaceWrite( + let writableRoots, let networkAccess, let excludeTmpdirEnvVar, let excludeSlashTmp): + try container.encode("workspaceWrite", forKey: .type) + try container.encode(writableRoots, forKey: .writableRoots) + try container.encode(networkAccess, forKey: .networkAccess) + try container.encode(excludeTmpdirEnvVar, forKey: .excludeTmpdirEnvVar) + try container.encode(excludeSlashTmp, forKey: .excludeSlashTmp) + case .dangerFullAccess: + try container.encode("dangerFullAccess", forKey: .type) + } + } + } +} + +extension AppServerAPI.Turn.Start { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var input: [AppServerAPI.UserInput] + package var approvalPolicy: String? + package var approvalsReviewer: String? + package var clientUserMessageID: String? + package var cwd: String? + package var effort: String? + package var model: String? + package var outputSchema: AppServerJSONValue? + package var personality: String? + package var sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? + package var serviceTier: String? + package var summary: String? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case input + case approvalPolicy + case approvalsReviewer + case clientUserMessageID = "clientUserMessageId" + case cwd + case effort + case model + case outputSchema + case personality + case sandboxPolicy + case serviceTier + case summary + } + + package init( + threadID: String, + input: [AppServerAPI.UserInput], + approvalPolicy: String? = nil, + approvalsReviewer: String? = nil, + clientUserMessageID: String? = nil, + cwd: String? = nil, + effort: String? = nil, + model: String? = nil, + outputSchema: AppServerJSONValue? = nil, + personality: String? = nil, + sandboxPolicy: AppServerAPI.Turn.SandboxPolicy? = nil, + serviceTier: String? = nil, + summary: String? = nil + ) { + self.threadID = threadID + self.input = input + self.approvalPolicy = approvalPolicy + self.approvalsReviewer = approvalsReviewer + self.clientUserMessageID = clientUserMessageID + self.cwd = cwd + self.effort = effort + self.model = model + self.outputSchema = outputSchema + self.personality = personality + self.sandboxPolicy = sandboxPolicy + self.serviceTier = serviceTier + self.summary = summary + } + } + + package struct Response: Codable, Equatable, Sendable { + package var turn: AppServerAPI.Turn.Payload + + package init(turn: AppServerAPI.Turn.Payload) { + self.turn = turn + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Turn.Start.Response + + package static let method = "turn/start" + package var params: AppServerAPI.Turn.Start.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Turn.Start.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn.Steer { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var expectedTurnID: String + package var input: [AppServerAPI.UserInput] + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case expectedTurnID = "expectedTurnId" + case input + } + + package init(threadID: String, expectedTurnID: String, input: [AppServerAPI.UserInput]) { + self.threadID = threadID + self.expectedTurnID = expectedTurnID + self.input = input + } + } + + package struct Response: Codable, Equatable, Sendable { + package var turnID: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + } + + package init(turnID: String) { + self.turnID = turnID + } + } + + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Turn.Steer.Response + + package static let method = "turn/steer" + package var params: AppServerAPI.Turn.Steer.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Turn.Steer.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Turn.Interrupt { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var turnID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + } + + package init(threadID: String, turnID: String) { + self.threadID = threadID + self.turnID = turnID + } + } +} + +extension AppServerAPI.Turn.Interrupt { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "turn/interrupt" + package var params: AppServerAPI.Turn.Interrupt.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Turn.Interrupt.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Rollback { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + package var numTurns: Int + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case numTurns + } + + package init(threadID: String, numTurns: Int) { + self.threadID = threadID + self.numTurns = numTurns + } + } +} + +extension AppServerAPI.Thread.Rollback { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/rollback" + package var params: AppServerAPI.Thread.Rollback.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Rollback.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Delete { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.Delete { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/delete" + package var params: AppServerAPI.Thread.Delete.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Delete.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package enum Status: String, Codable, Equatable, Sendable { + case notLoaded + case notSubscribed + case unsubscribed + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Response: Codable, Equatable, Sendable { + package var status: AppServerAPI.Thread.Unsubscribe.Status + + package init(status: AppServerAPI.Thread.Unsubscribe.Status) { + self.status = status + } + } +} + +extension AppServerAPI.Thread.Unsubscribe { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Thread.Unsubscribe.Response + + package static let method = "thread/unsubscribe" + package var params: AppServerAPI.Thread.Unsubscribe.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.Unsubscribe.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Thread.BackgroundTerminals.Clean { + package struct Params: Codable, Equatable, Sendable { + package var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } + + package init(threadID: String) { + self.threadID = threadID + } + } +} + +extension AppServerAPI.Thread.BackgroundTerminals.Clean { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "thread/backgroundTerminals/clean" + package var params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params + package var scope: AppServerAPI.RequestScope? { + .thread(params.threadID) + } + + package init(params: AppServerAPI.Thread.BackgroundTerminals.Clean.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Config.Read { + package struct Response: Codable, Equatable, Sendable { + package var config: AppServerAPI.Config.Snapshot + + package init(config: AppServerAPI.Config.Snapshot) { + self.config = config + } + } +} + +extension AppServerAPI.Config { + package struct Snapshot: Codable, Equatable, Sendable { + package var model: String? + package var reviewModel: String? + package var modelReasoningEffort: String? + package var serviceTier: String? + + enum CodingKeys: String, CodingKey { + case model + case reviewModel = "review_model" + case modelReasoningEffort = "model_reasoning_effort" + case serviceTier = "service_tier" + } + + package init( + model: String? = nil, + reviewModel: String? = nil, + modelReasoningEffort: String? = nil, + serviceTier: String? = nil + ) { + self.model = model + self.reviewModel = reviewModel + self.modelReasoningEffort = modelReasoningEffort + self.serviceTier = serviceTier + } + } +} + +extension AppServerAPI.Config.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Config.Read.Response + + package static let method = "config/read" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} + +extension AppServerAPI.Config { + package enum Value: Encodable, Equatable, Sendable { + case string(String) + case null + + package func encode(to encoder: Encoder) throws { + switch self { + case .string(let value): + var container = encoder.singleValueContainer() + try container.encode(value) + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } + } +} + +extension AppServerAPI.Config { + package enum MergeStrategy: String, Codable, Equatable, Sendable { + case replace + case upsert + } +} + +extension AppServerAPI.Config { + package struct Edit: Encodable, Equatable, Sendable { + package var keyPath: String + package var value: AppServerAPI.Config.Value + package var mergeStrategy: AppServerAPI.Config.MergeStrategy + + package init( + keyPath: String, + value: AppServerAPI.Config.Value, + mergeStrategy: AppServerAPI.Config.MergeStrategy = .replace + ) { + self.keyPath = keyPath + self.value = value + self.mergeStrategy = mergeStrategy + } + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Params: Encodable, Equatable, Sendable { + package var edits: [AppServerAPI.Config.Edit] + package var filePath: String? + package var expectedVersion: String? + package var reloadUserConfig: Bool + + package init( + edits: [AppServerAPI.Config.Edit], + filePath: String? = nil, + expectedVersion: String? = nil, + reloadUserConfig: Bool = true + ) { + self.edits = edits + self.filePath = filePath + self.expectedVersion = expectedVersion + self.reloadUserConfig = reloadUserConfig + } + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Response: Decodable, Equatable, Sendable { + package var status: String + package var version: String? + package var filePath: String? + } +} + +extension AppServerAPI.Config.BatchWrite { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Config.BatchWrite.Response + + package static let method = "config/batchWrite" + package var params: AppServerAPI.Config.BatchWrite.Params + + package init(params: AppServerAPI.Config.BatchWrite.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Model.List { + package struct Params: Codable, Equatable, Sendable { + package var cursor: String? + package var limit: Int? + package var includeHidden: Bool? + + package init( + cursor: String? = nil, + limit: Int? = nil, + includeHidden: Bool? = nil + ) { + self.cursor = cursor + self.limit = limit + self.includeHidden = includeHidden + } + } +} + +extension AppServerAPI.Model.List { + package struct Response: Codable, Equatable, Sendable { + package var data: [CodexModel] + package var nextCursor: String? + + package init( + data: [CodexModel], + nextCursor: String? = nil + ) { + self.data = data + self.nextCursor = nextCursor + } + } +} + +extension AppServerAPI.Model.List { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Model.List.Response + + package static let method = "model/list" + package var params: AppServerAPI.Model.List.Params + + package init(params: AppServerAPI.Model.List.Params = .init(includeHidden: true)) { + self.params = params + } + } +} + +extension AppServerAPI.Auth.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Read.Response + + package static let method = "account/read" + package var params: AppServerAPI.Account.Read.Params + + package init() { + self.params = .init(refreshToken: false) + } + } +} + +extension AppServerAPI.Account.Read { + package struct Params: Codable, Equatable, Sendable { + package var refreshToken: Bool + + package init(refreshToken: Bool) { + self.refreshToken = refreshToken + } + } +} + +extension AppServerAPI.Account.Read { + package struct Response: Codable, Equatable, Sendable { + package var account: AppServerAPI.Account.Snapshot? + package var requiresOpenAIAuth: Bool + + enum CodingKeys: String, CodingKey { + case account + case requiresOpenAIAuth = "requiresOpenaiAuth" + } + + package init( + account: AppServerAPI.Account.Snapshot? = nil, requiresOpenAIAuth: Bool = false + ) { + self.account = account + self.requiresOpenAIAuth = requiresOpenAIAuth + } + } +} + +extension AppServerAPI.Account.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Read.Response + + package static let method = "account/read" + package var params: AppServerAPI.Account.Read.Params + + package init(params: AppServerAPI.Account.Read.Params = .init(refreshToken: false)) { + self.params = params + } + } +} + +extension AppServerAPI.Account.RateLimits.Read { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.RateLimits.Response + + package static let method = "account/rateLimits/read" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Response: Codable, Equatable, Sendable { + package var rateLimits: AppServerAPI.Account.RateLimits.Snapshot + package var rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? + + enum CodingKeys: String, CodingKey { + case rateLimits + case rateLimitsByLimitID = "rateLimitsByLimitId" + } + + package init( + rateLimits: AppServerAPI.Account.RateLimits.Snapshot, + rateLimitsByLimitID: [String: AppServerAPI.Account.RateLimits.Snapshot]? = nil + ) { + self.rateLimits = rateLimits + self.rateLimitsByLimitID = rateLimitsByLimitID + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Snapshot: Codable, Equatable, Sendable { + package var limitID: String? + package var primary: AppServerAPI.Account.RateLimits.Window? + package var secondary: AppServerAPI.Account.RateLimits.Window? + package var planType: String? + + enum CodingKeys: String, CodingKey { + case limitID = "limitId" + case primary + case secondary + case planType + } + + package init( + limitID: String? = nil, + primary: AppServerAPI.Account.RateLimits.Window? = nil, + secondary: AppServerAPI.Account.RateLimits.Window? = nil, + planType: String? = nil + ) { + self.limitID = limitID + self.primary = primary + self.secondary = secondary + self.planType = planType + } + } +} + +extension AppServerAPI.Account.RateLimits { + package struct Window: Codable, Equatable, Sendable { + package var usedPercent: Int + package var windowDurationMins: Int? + package var resetsAt: Int64? + + private enum CodingKeys: String, CodingKey { + case usedPercent + case usedPercentSnake = "used_percent" + case windowDurationMins + case windowDurationMinsSnake = "window_duration_mins" + case windowMinutes = "window_minutes" + case resetsAt + case resetsAtSnake = "resets_at" + } + + package init( + usedPercent: Int, + windowDurationMins: Int? = nil, + resetsAt: Int64? = nil + ) { + self.usedPercent = usedPercent + self.windowDurationMins = windowDurationMins + self.resetsAt = resetsAt + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let usedPercent = try Self.decodeNumber( + for: [.usedPercent, .usedPercentSnake], + in: container + ) + self.usedPercent = Int(usedPercent.rounded()) + windowDurationMins = try Self.decodeOptionalInt( + for: [.windowDurationMins, .windowDurationMinsSnake, .windowMinutes], + in: container + ) + resetsAt = try Self.decodeOptionalInt64( + for: [.resetsAt, .resetsAtSnake], + in: container + ) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(usedPercent, forKey: .usedPercent) + try container.encodeIfPresent(windowDurationMins, forKey: .windowDurationMins) + try container.encodeIfPresent(resetsAt, forKey: .resetsAt) + } + + private static func decodeNumber( + for keys: [CodingKeys], + in container: KeyedDecodingContainer + ) throws -> Double { + for key in keys where container.contains(key) { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + throw DecodingError.typeMismatch( + Double.self, + .init( + codingPath: container.codingPath + [key], + debugDescription: "Expected numeric rate-limit field." + ) + ) + } + throw DecodingError.keyNotFound( + keys[0], + .init( + codingPath: container.codingPath, + debugDescription: "Missing rate-limit field \(keys[0].stringValue)." + ) + ) + } + + private static func decodeOptionalInt( + for keys: [CodingKeys], + in container: KeyedDecodingContainer + ) throws -> Int? { + guard let value = try decodeOptionalInt64(for: keys, in: container) else { + return nil + } + guard let intValue = Int(exactly: value) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Rate-limit integer value exceeds platform Int range." + ) + ) + } + return intValue + } + + private static func decodeOptionalInt64( + for keys: [CodingKeys], + in container: KeyedDecodingContainer + ) throws -> Int64? { + for key in keys where container.contains(key) { + if try container.decodeNil(forKey: key) { + return nil + } + if let value = try? container.decode(Int64.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return Int64(value.rounded()) + } + throw DecodingError.typeMismatch( + Int64.self, + .init( + codingPath: container.codingPath + [key], + debugDescription: "Expected integer rate-limit field." + ) + ) + } + return nil + } + } +} + +extension AppServerAPI.Account.RateLimits.Response { + package var codexRateLimitWindows: + [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] + { + Self.rateLimitWindows(from: codexSnapshot) + } + + package var codexPlanType: String? { + codexSnapshot?.planType + } + + private var codexSnapshot: AppServerAPI.Account.RateLimits.Snapshot? { + if let codexSnapshot = rateLimitsByLimitID?["codex"] { + return codexSnapshot + } + if let codexSnapshot = rateLimitsByLimitID?.first(where: { limitID, snapshot in + Self.isCodexRateLimit(limitID) || Self.isCodexRateLimit(snapshot.limitID) + })?.value { + return codexSnapshot + } + if Self.isCodexRateLimit(rateLimits.limitID) { + return rateLimits + } + return nil + } + + private static func rateLimitWindows( + from snapshot: AppServerAPI.Account.RateLimits.Snapshot? + ) -> [(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?)] { + [snapshot?.primary, snapshot?.secondary].compactMap { window in + guard let window, + let duration = window.windowDurationMins + else { + return nil + } + return ( + windowDurationMinutes: duration, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } + } + + package static func isCodexRateLimit(_ limitID: String?) -> Bool { + let trimmedLimitID = limitID?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedLimitID = + if let trimmedLimitID, trimmedLimitID.isEmpty == false { + trimmedLimitID + } else { + "codex" + } + return normalizedLimitID == "codex" || normalizedLimitID.hasPrefix("codex_") + } +} + +extension AppServerAPI.Account { + package struct Snapshot: Codable, Equatable, Sendable { + package enum Kind: String, Codable, Equatable, Sendable { + case chatGPT = "chatgpt" + case apiKey + case amazonBedrock + } + + package var id: String + package var kind: Kind + package var label: String + package var planType: String? + + private enum CodingKeys: String, CodingKey { + case type + case email + case planType + } + + package init( + kind: Kind, + id: String, + label: String, + planType: String? = nil + ) { + self.id = id + self.kind = kind + self.label = label + self.planType = planType + } + + package init(email: String, planType: String) { + self.init( + kind: .chatGPT, + id: Self.normalizedAccountID(email), + label: email, + planType: planType + ) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .type) + switch kind { + case .apiKey: + self.init(kind: .apiKey, id: "api-key", label: "API Key") + case .chatGPT: + let email = try container.decodeIfPresent(String.self, forKey: .email) + let normalizedEmail = email.map(Self.normalizedAccountID) ?? "" + let label = email?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayLabel = label.flatMap { $0.isEmpty ? nil : $0 } ?? "ChatGPT" + self.init( + kind: .chatGPT, + id: normalizedEmail.isEmpty ? "chatgpt" : normalizedEmail, + label: displayLabel, + planType: try container.decodeIfPresent(String.self, forKey: .planType) + ) + case .amazonBedrock: + self.init(kind: .amazonBedrock, id: "amazon-bedrock", label: "Amazon Bedrock") + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .type) + switch kind { + case .apiKey, .amazonBedrock: + break + case .chatGPT: + try container.encode(label, forKey: .email) + try container.encodeIfPresent(planType, forKey: .planType) + } + } + + private static func normalizedAccountID(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + } +} + +extension AppServerAPI.Account.Login { + package enum Params: Codable, Equatable, Sendable { + case chatGPT(codexStreamlinedLogin: Bool = true) + case apiKey(String) + + private enum Kind: String, Codable { + case apiKey + case chatGPT = "chatgpt" + } + + private enum CodingKeys: String, CodingKey { + case type + case apiKey + case codexStreamlinedLogin + } + + package var type: String { + switch self { + case .chatGPT: + "chatgpt" + case .apiKey: + "apiKey" + } + } + + package var apiKey: String? { + guard case .apiKey(let apiKey) = self else { + return nil + } + return apiKey + } + + package var codexStreamlinedLogin: Bool? { + guard case .chatGPT(let codexStreamlinedLogin) = self else { + return nil + } + return codexStreamlinedLogin + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .type) { + case .chatGPT: + guard container.contains(.apiKey) == false else { + throw DecodingError.dataCorruptedError( + forKey: .apiKey, + in: container, + debugDescription: "ChatGPT login parameters cannot contain an API key." + ) + } + self = .chatGPT( + codexStreamlinedLogin: try container.decode( + Bool.self, + forKey: .codexStreamlinedLogin + ) + ) + case .apiKey: + guard container.contains(.codexStreamlinedLogin) == false else { + throw DecodingError.dataCorruptedError( + forKey: .codexStreamlinedLogin, + in: container, + debugDescription: "API-key login parameters cannot contain ChatGPT options." + ) + } + self = .apiKey(try container.decode(String.self, forKey: .apiKey)) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .chatGPT(let codexStreamlinedLogin): + try container.encode(Kind.chatGPT, forKey: .type) + try container.encode(codexStreamlinedLogin, forKey: .codexStreamlinedLogin) + case .apiKey(let apiKey): + try container.encode(Kind.apiKey, forKey: .type) + try container.encode(apiKey, forKey: .apiKey) + } + } + } +} + +extension AppServerAPI.Account.Login { + package enum Response: Codable, Equatable, Sendable { + case apiKey + case chatgpt(loginID: String, authURL: String) + case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String) + case chatgptAuthTokens + + private enum CodingKeys: String, CodingKey { + case type + case loginID = "loginId" + case authURL = "authUrl" + case verificationURL = "verificationUrl" + case userCode + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "apiKey": + self = .apiKey + case "chatgpt": + self = .chatgpt( + loginID: try container.decode(String.self, forKey: .loginID), + authURL: try container.decode(String.self, forKey: .authURL) + ) + case "chatgptDeviceCode": + self = .chatgptDeviceCode( + loginID: try container.decode(String.self, forKey: .loginID), + verificationURL: try container.decode(String.self, forKey: .verificationURL), + userCode: try container.decode(String.self, forKey: .userCode) + ) + case "chatgptAuthTokens": + self = .chatgptAuthTokens + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported login response type: \(type)" + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .apiKey: + try container.encode("apiKey", forKey: .type) + case .chatgpt(let loginID, let authURL): + try container.encode("chatgpt", forKey: .type) + try container.encode(loginID, forKey: .loginID) + try container.encode(authURL, forKey: .authURL) + case .chatgptDeviceCode(let loginID, let verificationURL, let userCode): + try container.encode("chatgptDeviceCode", forKey: .type) + try container.encode(loginID, forKey: .loginID) + try container.encode(verificationURL, forKey: .verificationURL) + try container.encode(userCode, forKey: .userCode) + case .chatgptAuthTokens: + try container.encode("chatgptAuthTokens", forKey: .type) + } + } + } +} + +extension AppServerAPI.Account.Login.Response { + package var pendingLoginID: String? { + switch self { + case .chatgpt(let loginID, _), .chatgptDeviceCode(let loginID, _, _): + loginID + case .apiKey, .chatgptAuthTokens: + nil + } + } +} + +extension AppServerAPI.Account.Login.Start { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Login.Response + + package static let method = "account/login/start" + package var params: AppServerAPI.Account.Login.Params + + package init(params: AppServerAPI.Account.Login.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Params: Codable, Equatable, Sendable { + package var loginID: String + + enum CodingKeys: String, CodingKey { + case loginID = "loginId" + } + + package init(loginID: String) { + self.loginID = loginID + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Response: Codable, Equatable, Sendable { + package var status: String + + package init(status: String = "canceled") { + self.status = status + } + } +} + +extension AppServerAPI.Account.Login.Cancel { + package struct Request: AppServerAPI.Request { + package typealias Response = AppServerAPI.Account.Login.Cancel.Response + + package static let method = "account/login/cancel" + package var params: AppServerAPI.Account.Login.Cancel.Params + + package init(params: AppServerAPI.Account.Login.Cancel.Params) { + self.params = params + } + } +} + +extension AppServerAPI.Account.Logout { + package struct Request: AppServerAPI.Request { + package typealias Response = EmptyResponse + + package static let method = "account/logout" + package var params: EmptyResponse + + package init() { + self.params = .init() + } + } +} diff --git a/Sources/CodexAppServerKit/CodexAppServer.swift b/Sources/CodexAppServerKit/CodexAppServer.swift new file mode 100644 index 00000000..19df2082 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServer.swift @@ -0,0 +1,1548 @@ +import Foundation +import Synchronization + +/// A live connection to a Codex app-server process. +/// +/// `CodexAppServer` owns the app-server transport, performs the initial +/// JSON-RPC handshake, and routes server notifications to thread, turn, and +/// login domain objects. +public actor CodexAppServer { + /// Options for creating a Codex app-server container. + public struct Configuration: Sendable { + public struct Deadlines: Equatable, Sendable { + public var handshake: Duration? + public var request: Duration? + + public init( + handshake: Duration? = nil, + request: Duration? = nil + ) { + self.handshake = handshake + self.request = request + } + } + + /// Options for launching a local `codex app-server` process. + public struct LocalProcess: Sendable { + /// The `codex` executable path or command name. + /// + /// Set this when the executable is not available through the process + /// environment. When `nil`, the default transport command is used. + public var executable: String? + + /// Command-line arguments passed to the app-server executable. + /// + /// When `nil`, the transport uses the default arguments for starting + /// `codex app-server`. + public var arguments: [String]? + + /// Environment variables supplied to the app-server process. + public var environment: [String: String] + + /// The Codex home directory used by the app-server process. + public var codexHomeURL: URL + + /// Creates a configuration for launching a local app-server process. + /// + /// - Parameters: + /// - executable: The `codex` executable path or command name. + /// - arguments: Command-line arguments for the app-server process. + /// - environment: Environment variables for the app-server process. + /// - codexHomeURL: Codex home directory, or `nil` to use the local-process default. + public init( + executable: String? = nil, + arguments: [String]? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + codexHomeURL: URL? = nil + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.codexHomeURL = codexHomeURL ?? Self.defaultCodexHomeURL(environment: environment) + } + + /// Returns the default Codex home for a local app-server process. + /// + /// The value honors `CODEX_HOME` first. On macOS command-line runs, + /// it then matches the Codex CLI convention of `~/.codex`. Other + /// Apple platform environments prefer Application Support so the + /// default stays inside the app container when this API is compiled + /// for a non-command-line host. + public static func defaultCodexHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectoryForCurrentUser: URL = FileManager.default.homeDirectoryForCurrentUser, + applicationSupportDirectory: URL? = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first + ) -> URL { + if let codexHome = environment["CODEX_HOME"]?.trimmingCharacters( + in: .whitespacesAndNewlines + ), + codexHome.isEmpty == false { + return URL(fileURLWithPath: codexHome, isDirectory: true) + } +#if os(macOS) + if let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + home.isEmpty == false { + return URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".codex", isDirectory: true) + } +#endif + if let applicationSupportDirectory { + return applicationSupportDirectory + .appendingPathComponent("Codex", isDirectory: true) + } + return homeDirectoryForCurrentUser + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Codex", isDirectory: true) + } + } + + /// Local process launch settings for the app-server runtime. + public var localProcess: LocalProcess + + /// The client name sent in the app-server `initialize` request. + public var clientName: String + + /// The client version sent in the app-server `initialize` request. + public var clientVersion: String + + /// Monotonic request and handshake deadlines. `nil` disables the + /// corresponding deadline. + public var deadlines: Deadlines + + package var deadlineClock: CodexDeadlineClock + package var clock: CodexAppServerClock + /// Handles typed requests initiated by the app-server. + /// + /// A `nil` handler uses the built-in policy, which declines approvals, + /// cancels interactive requests, and rejects unsupported providers. + public var serverRequestHandler: CodexAppServerRequestHandler? + + /// Creates a configuration for a Codex app-server container. + /// + /// - Parameters: + /// - localProcess: Local process launch settings. + /// - clientName: Client name sent during app-server initialization. + /// - clientVersion: Client version sent during app-server initialization. + /// - deadlines: Monotonic request and handshake deadlines. + /// - serverRequestHandler: Optional host policy for app-server-initiated requests. + public init( + localProcess: LocalProcess = .init(), + clientName: String = "CodexAppServerKit", + clientVersion: String = "1", + deadlines: Deadlines = .init(), + serverRequestHandler: CodexAppServerRequestHandler? = nil + ) { + self.localProcess = localProcess + self.clientName = clientName + self.clientVersion = clientVersion + self.deadlines = deadlines + self.deadlineClock = .continuous + self.clock = .init() + self.serverRequestHandler = serverRequestHandler + } + + package init( + localProcess: LocalProcess = .init(), + clientName: String = "CodexAppServerKit", + clientVersion: String = "1", + deadlines: Deadlines = .init(), + deadlineClock: CodexDeadlineClock, + clock: CodexAppServerClock = .init(), + serverRequestHandler: CodexAppServerRequestHandler? = nil + ) { + self.localProcess = localProcess + self.clientName = clientName + self.clientVersion = clientVersion + self.deadlines = deadlines + self.deadlineClock = deadlineClock + self.clock = clock + self.serverRequestHandler = serverRequestHandler + } + + /// Applies CodexAppServerKit's built-in policy to a server request. + /// + /// Custom handlers can call this for requests they do not override. + public static func defaultServerRequestHandler( + request: CodexAppServerRequest + ) async throws -> CodexAppServerRequestResolution { + CodexAppServerRequestCodec.builtInResolution( + for: request, + clock: .init() + ) + } + + package static func defaultServerRequestHandler( + clock: CodexAppServerClock + ) -> CodexAppServerRequestHandler { + { request in + CodexAppServerRequestCodec.builtInResolution(for: request, clock: clock) + } + } + } + + private let client: AppServerClient + private let router: CodexAppServerNotificationRouter + private let turnReplayStore: TurnReplayStore + private let connectionEventHub: ConnectionEventHub + private let connectionLease: AppServerConnectionLease + private let loginRegistry: LoginRegistry + private let reviewRestartCoordinator: ReviewRestartCoordinator + + package nonisolated var appServerClient: AppServerClient { + client + } + + /// Starts a Codex app-server process and initializes the client session. + /// + /// The initializer completes after the app-server has accepted the + /// `initialize` request and notification routing is ready. + /// + /// - Parameter configuration: Container and local-process configuration. + /// - Throws: A transport, JSON-RPC, or app-server initialization error. + public init(configuration: Configuration = .init()) async throws { + let transportConfiguration = AppServerProcessTransport.Configuration( + executable: configuration.localProcess.executable, + arguments: configuration.localProcess.arguments, + environment: configuration.localProcess.environment, + codexHomeURL: configuration.localProcess.codexHomeURL + ) + let transport: AppServerProcessTransport + let connectionEventHub = ConnectionEventHub() + do { + transport = try AppServerProcessTransport( + configuration: transportConfiguration, + connectionEventHub: connectionEventHub + ) + } catch let failure as CodexLaunchFailure { + throw CodexAppServerError.launch(failure) + } catch is CancellationError { + throw CancellationError() + } catch { + throw CodexAppServerError.launch(.spawn( + executable: transportConfiguration.executable, + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + )) + } + let connectionCloseAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + deadlines: configuration.deadlines, + deadlineClock: configuration.deadlineClock, + connectionCloseAction: connectionCloseAction + ) + let turnReplayStore = TurnReplayStore() + let threadEventHub = ThreadEventHub() + let loginRegistry = LoginRegistry(sleep: configuration.deadlineClock.sleep) + let router = CodexAppServerNotificationRouter( + client: client, + turnReplayStore: turnReplayStore, + threadEventHub: threadEventHub, + loginRegistry: loginRegistry + ) + let connection = AppServerConnection( + transport: transport, + client: client, + router: router, + turnReplayStore: turnReplayStore, + serverRequestHandler: configuration.serverRequestHandler + ?? Configuration.defaultServerRequestHandler(clock: configuration.clock) + ) + let supervisor = ConnectionSupervisor(connection: connection) + connectionCloseAction.bind(to: supervisor) + let connectionLease = AppServerConnectionLease( + supervisor: supervisor, + processTerminationToken: transport.processTerminationToken + ) + await supervisor.start() + do { + _ = try await client.initialize( + clientName: configuration.clientName, + clientVersion: configuration.clientVersion + ) + } catch { + await supervisor.closeConnection() + throw error + } + self.client = client + self.router = router + self.turnReplayStore = turnReplayStore + self.connectionEventHub = client.connectionEventHub + self.connectionLease = connectionLease + self.loginRegistry = loginRegistry + self.reviewRestartCoordinator = ReviewRestartCoordinator() + } + + package init( + transport: any JSONRPC.Transport + ) async throws { + let connectionCloseAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + connectionCloseAction: connectionCloseAction + ) + let configuration = Configuration() + let turnReplayStore = TurnReplayStore() + let threadEventHub = ThreadEventHub() + let loginRegistry = LoginRegistry(sleep: configuration.deadlineClock.sleep) + let router = CodexAppServerNotificationRouter( + client: client, + turnReplayStore: turnReplayStore, + threadEventHub: threadEventHub, + loginRegistry: loginRegistry + ) + let connection = AppServerConnection( + transport: transport, + client: client, + router: router, + turnReplayStore: turnReplayStore, + serverRequestHandler: Configuration.defaultServerRequestHandler( + clock: configuration.clock + ) + ) + let supervisor = ConnectionSupervisor(connection: connection) + connectionCloseAction.bind(to: supervisor) + let connectionLease = AppServerConnectionLease( + supervisor: supervisor, + processTerminationToken: ProcessTerminationToken() + ) + await supervisor.start() + do { + _ = try await client.initialize( + clientName: configuration.clientName, + clientVersion: configuration.clientVersion + ) + } catch { + await supervisor.closeConnection() + throw error + } + self.client = client + self.router = router + self.turnReplayStore = turnReplayStore + self.connectionEventHub = client.connectionEventHub + self.connectionLease = connectionLease + self.loginRegistry = loginRegistry + self.reviewRestartCoordinator = ReviewRestartCoordinator() + } + + package init( + client: AppServerClient, + router: CodexAppServerNotificationRouter, + connectionLease: AppServerConnectionLease + ) { + self.client = client + self.router = router + self.turnReplayStore = router.turnReplayStore + self.connectionEventHub = client.connectionEventHub + self.connectionLease = connectionLease + self.loginRegistry = router.loginRegistry + self.reviewRestartCoordinator = ReviewRestartCoordinator() + } + + package static func testing( + transport: any JSONRPC.Transport + ) async throws -> CodexAppServer { + try await CodexAppServer(transport: transport) + } + + /// Closes the app-server connection and stops notification routing. + /// + /// Call this when the container is no longer needed. Closing is idempotent + /// from the perspective of public callers. + public func close() async { + _ = await reviewRestartCoordinator.invalidateAllAndWait() + await connectionLease.closeConnection() + } + + /// Returns connection-scoped diagnostics and the compact terminal event. + /// + /// This subscription does not retain the app-server connection or its lease. + /// Call ``CodexConnectionEvents/cancel()`` to release only this subscriber. + public func connectionEvents() -> CodexConnectionEvents { + connectionEventHub.events() + } + + /// Returns account-related app-server notifications as typed domain events. + /// + /// A malformed known current-v2 notification terminates connection-wide routing, including + /// this sequence and active thread or turn sequences, with a typed + /// ``CodexAppServerError/connectionTerminated(_:)`` protocol violation. Call + /// ``CodexAccountEvents/cancel()`` + /// to release only this subscription without closing other routing. + public func accountEvents() async -> CodexAccountEvents { + await router.accountEvents() + } + + /// Creates a new Codex thread in a workspace. + /// + /// - Parameters: + /// - workspace: The workspace directory for the thread. + /// - instructions: Optional base and developer instructions. + /// - options: Thread creation options, including model, approval, and sandbox settings. + /// - Returns: A domain handle for the created thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func startThread( + in workspace: URL, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init() + ) async throws -> CodexThread { + let approvalMode = options.approvalMode ?? .autoReview + let response = try await client.send( + AppServerAPI.Thread.Start.Request( + params: .init( + cwd: workspace.path, + model: options.model, + modelProvider: options.modelProvider, + ephemeral: options.ephemeral, + baseInstructions: instructions?.base, + developerInstructions: instructions?.developer, + approvalPolicy: approvalMode.approvalPolicy, + approvalsReviewer: approvalMode.approvalsReviewer, + sandbox: options.sandbox?.threadSandboxValue, + serviceName: options.serviceName, + serviceTier: options.serviceTier, + personality: options.personality?.rawValue, + config: options.config?.mapValues(\.appServerJSONValue), + permissions: options.permissions?.appServerPermissions, + sessionStartSource: options.sessionStartSource?.appServerSource, + threadSource: options.threadSource?.appServerSource + ) + ), + onPostWriteCancellation: { [client] response in + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: response.threadID) + ) + ) + } + ) + return CodexThread( + id: .init(rawValue: response.threadID), + workspace: workspace, + model: response.model ?? options.model, + client: client, + router: router, + connectionLease: connectionLease + ) + } + + /// Starts a Codex code review in a workspace. + /// + /// This creates a source thread for `workspace` and starts the app-server + /// review lifecycle from that thread, so callers do not need to manually + /// sequence `startThread` and `CodexThread.startReview`. + /// + /// - Parameters: + /// - workspace: The workspace directory to review. + /// - target: The repository changes or custom instructions to review. + /// - instructions: Optional base and developer instructions for the source thread. + /// - options: Thread creation options, including model, approval, and sandbox settings. + /// - delivery: Whether the app-server should run the review inline or in a detached review thread. + /// - Returns: A live review session. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func startReview( + in workspace: URL, + target: CodexReviewTarget, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init(), + delivery: CodexReviewDelivery = .inline + ) async throws -> CodexReviewSession { + try Task.checkCancellation() + let thread = try await startThread( + in: workspace, + instructions: instructions, + options: options + ) + do { + try Task.checkCancellation() + } catch { + await deleteThreadIgnoringCallerCancellation(thread.id) + throw error + } + + let review: CodexReviewSession + do { + review = try await thread.startReview( + target: target, + delivery: delivery + ) + } catch { + await deleteThreadIgnoringCallerCancellation(thread.id) + throw error + } + + do { + try Task.checkCancellation() + return review + } catch { + await cleanupReviewIgnoringCallerCancellation(review.identity) + throw error + } + } + + package func reviewEventThread( + for review: CodexReviewSession, + workspace: URL + ) -> CodexThread { + CodexThread( + id: review.activeTurnThreadID, + workspace: workspace, + model: review.model, + client: client, + router: router, + connectionLease: connectionLease + ) + } + + /// Resumes an existing Codex thread. + /// + /// - Parameters: + /// - id: The thread identifier to resume. + /// - options: Resume options that may override the stored thread context. + /// - Returns: A domain handle for the resumed thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func resumeThread( + _ id: CodexThreadID, + options: CodexThread.ResumeOptions = .init() + ) async throws -> CodexThread { + let response: AppServerAPI.Thread.Resume.Response = try await withThreadEventGeneration( + id, + router: router + ) { generation in + try await client.send( + AppServerAPI.Thread.Resume.Request( + threadID: id.rawValue, + params: threadStartParams(options: options) + ), + reconcileResponse: { response in + if let latestTurn = response.thread.turns?.last { + let snapshot = Self.turnSnapshots(from: [latestTurn])[0] + if snapshot.state == .inProgress { + generation.seedProvisionalResumeSnapshot(snapshot) + } + } + }, + onWriteAccepted: generation.acceptWrite, + onResponseRejected: generation.rejectResponse, + onResponseAccepted: generation.acceptResponse + ) + } + return await thread(from: response.thread, model: response.model ?? options.model) + } + + /// Restores a persisted app-server review run as a live review session handle. + /// + /// The restored session can consume review events and cancel the active + /// review turn. The active turn thread is resumed first so app-server has + /// the stored thread context loaded before the review handle is rebuilt. + /// + /// - Parameters: + /// - identity: Persisted review run identity. + /// - threadOptions: Resume options for the active turn thread. When `model` is + /// `nil`, `identity.model` is used. + /// - Returns: A live review session handle for the persisted run. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func resumeReview( + _ identity: CodexReviewIdentity, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewSession { + var threadOptions = threadOptions + if threadOptions.model == nil { + threadOptions.model = identity.model + } + let activeTurnThreadID = identity.activeTurnThreadID + let initialTurn = CodexTurnSnapshot( + id: identity.turnID, + state: .inProgress, + itemsLoadState: .notLoaded + ) + let reservation = await turnReplayStore.reserveRestoredGeneration( + turnID: identity.turnID, + initialSnapshot: initialTurn, + connectionLease: connectionLease + ) + let state = reservation.state + await router.seedTurn(identity.turnID, threadID: activeTurnThreadID) + do { + let activeThread = try await resumeThread( + activeTurnThreadID, + options: threadOptions + ) + await turnReplayStore.commitRestoredGeneration(reservation) + if await state.snapshot() != .live { + await router.discardTurnAssociation( + identity.turnID, + threadID: activeTurnThreadID + ) + } + return await activeThread.reviewSession( + identity, + model: activeThread.model ?? identity.model, + initialTurn: initialTurn, + state: state + ) + } catch { + let removedGeneration = await turnReplayStore.discardRestoredGeneration(reservation) + if removedGeneration { + await router.discardTurnAssociation( + identity.turnID, + threadID: activeTurnThreadID + ) + } + throw error + } + } + + /// Cancels a running review and prepares it for a later restart. + /// + /// The returned token is process-local to this ``CodexAppServer`` instance. + /// Cleanup ownership for the interrupted review is retained internally until + /// ``cleanupReview(_:additionalCleanupThreadIDs:)`` is called for the same + /// source thread. + /// + /// - Parameters: + /// - identity: Persisted review run identity to interrupt. + /// - threadOptions: Resume options for the active turn thread. When `model` is + /// `nil`, `identity.model` is used. + /// - Returns: A token that can be passed to ``restartPreparedReview(_:target:delivery:threadOptions:)``. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func prepareReviewRestart( + _ identity: CodexReviewIdentity, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewRestartToken { + try await reviewRestartCoordinator.prepare( + identity, + operations: .init { [self] identities in + identities.record(identity) + let review = try await resumeReview( + identity, + threadOptions: threadOptions + ) + let acknowledgement = try await review + .interruptAndAwaitTerminalAcknowledgement { + retryCancellation in + if retryCancellation.turnID != Optional(identity.turnID) { + identities.record(Self.reviewCleanupIdentity( + for: retryCancellation, + sourceIdentity: identity, + model: review.model + )) + } + } + guard case .interrupted = acknowledgement.outcome else { + throw CodexTransportFailure.contractViolation( + message: "Preparing a review restart requires an interrupted terminal acknowledgement." + ) + } + let cancellation = acknowledgement.cancellation + identities.record(Self.reviewCleanupIdentity( + for: cancellation, + sourceIdentity: identity, + model: review.model + )) + return .init( + rollbackThreadID: cancellation.threadID, + rollbackModel: review.model + ) + } + ) + } + + /// Restarts a review that was previously prepared by ``prepareReviewRestart(_:threadOptions:)``. + /// + /// The restart first reloads and rolls back the thread that owned the + /// interrupted active turn, then reloads the source thread and starts a new + /// review from that source. + /// + /// - Parameters: + /// - token: Token returned by ``prepareReviewRestart(_:threadOptions:)``. + /// - target: The repository changes or custom instructions to review. + /// - delivery: Whether the app-server should run the review inline or in a detached review thread. + /// - threadOptions: Resume options for the source thread. For inline + /// reviews, `token.interruptedIdentity.model` is used when `model` is + /// `nil`; detached review restarts leave source-thread model selection + /// to app-server unless the caller supplies an explicit model. + /// - Returns: A live review session for the restarted review. + /// - Throws: ``CodexAppServerError/reviewRestartUnavailable(_:)`` when the token is stale. + public func restartPreparedReview( + _ token: CodexReviewRestartToken, + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewSession { + let signature = ReviewRestartCoordinator.RestartInvocationSignature( + target: target, + delivery: delivery, + threadOptions: threadOptions + ) + return try await reviewRestartCoordinator.restart( + token, + signature: signature, + operations: .init( + loadRollbackThread: { [self] context in + try await resumeThread( + context.rollbackThreadID, + options: .init(model: context.rollbackModel) + ) + }, + rollback: { thread in + try await thread.rollback(turnCount: 1) + }, + loadSourceThread: { [self] context in + var sourceThreadOptions = threadOptions + if sourceThreadOptions.model == nil, + context.interruptedIdentity.activeTurnThreadID + == context.interruptedIdentity.sourceThreadID { + sourceThreadOptions.model = context.interruptedIdentity.model + } + return try await resumeThread( + context.interruptedIdentity.sourceThreadID, + options: sourceThreadOptions + ) + }, + startReview: { sourceThread, identities in + try await sourceThread.startReview( + target: target, + delivery: delivery, + onPostWriteCancellation: { review in + identities.record(review.identity) + try await Self.interruptLateReviewSession(review) + } + ) + }, + cleanupLateSession: Self.interruptLateReviewSession + ) + ) + } + + /// Invalidates one prepared restart and returns every review identity whose + /// cleanup ownership was retained for its source thread. + /// + /// If preparation or restart is in flight, this operation cancels and + /// awaits it. A replacement session that arrives after invalidation is + /// interrupted before its identity is returned. + public func discardPreparedReviewRestart( + _ token: CodexReviewRestartToken + ) async -> [CodexReviewIdentity] { + await reviewRestartCoordinator.invalidate(token) + } + + /// Invalidates all prepared restarts and transfers their retained cleanup + /// identities grouped by source thread. + /// + /// This terminally closes restart preparation for this app-server instance. + /// Call it while stopping the owning runtime, before ``close()``. + public func discardAllPreparedReviewRestarts() + async -> [CodexThreadID: [CodexReviewIdentity]] { + await reviewRestartCoordinator.invalidateAllAndWait() + } + + package func waitForReviewRestartWaiterCountForTesting( + tokenID: CodexReviewRestartToken.ID, + atLeast minimumCount: Int + ) async { + await reviewRestartCoordinator.waitForRestartWaiterCountForTesting( + tokenID: tokenID, + atLeast: minimumCount + ) + } + + package func waitForReviewRestartInvalidationRequestForTesting( + tokenID: CodexReviewRestartToken.ID + ) async { + await reviewRestartCoordinator.waitForInvalidationRequestForTesting( + tokenID: tokenID + ) + } + + /// Deletes all app-server threads owned by a review lifecycle and reports + /// each failed deletion in source-last attempt order. + /// + /// Retained cleanup identities from prepared restarts are included, duplicate + /// thread identifiers are removed, and the source thread is deleted last. + /// Retained restart identities remain registered when any deletion fails so + /// the caller can retry without losing thread identities known only to this + /// app-server generation. + /// + /// - Parameters: + /// - identity: Review identity whose source thread owns the lifecycle. + /// - additionalCleanupThreadIDs: Extra cleanup ID sequences, in preferred + /// per-sequence order, to merge with retained review cleanup IDs. + @discardableResult + public func cleanupReview( + _ identity: CodexReviewIdentity, + additionalCleanupThreadIDs: [[CodexThreadID]] = [] + ) async -> CodexReviewCleanupResult { + let sourceThreadID = identity.sourceThreadID + let retainedIdentities = await reviewRestartCoordinator + .invalidateAndTakeRetainedIdentities(sourceThreadID: sourceThreadID) + + let cleanupThreadIDs = Self.orderedReviewCleanupThreadIDs( + sourceThreadID: sourceThreadID, + sequences: retainedIdentities.map(\.cleanupThreadIDs) + + [identity.cleanupThreadIDs] + + additionalCleanupThreadIDs + ) + var failures: [CodexReviewCleanupFailure] = [] + for threadID in cleanupThreadIDs { + do { + try await deleteThread(threadID) + } catch { + failures.append(.init( + threadID: threadID, + message: error.localizedDescription + )) + } + } + if failures.isEmpty == false { + await reviewRestartCoordinator.restoreRetainedIdentities( + retainedIdentities + ) + } + return .init( + attemptedThreadIDs: cleanupThreadIDs, + failures: failures + ) + } + + /// Forks an existing Codex thread into a new thread. + /// + /// - Parameters: + /// - id: The source thread identifier. + /// - options: Options for the forked thread. + /// - Returns: A domain handle for the forked thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func forkThread( + _ id: CodexThreadID, + options: CodexThread.Options = .init() + ) async throws -> CodexThread { + let response = try await client.send( + AppServerAPI.Thread.Fork.Request( + threadID: id.rawValue, + params: threadStartParams(options: options) + ), + onPostWriteCancellation: { [client] response in + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: response.thread.id) + ) + ) + } + ) + return await thread(from: response.thread) + } + + /// Restores an archived Codex thread. + /// + /// - Parameter id: The archived thread identifier. + /// - Returns: A domain handle for the restored thread. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func unarchiveThread(_ id: CodexThreadID) async throws -> CodexThread { + let response = try await sendUnarchiveThread(id) + return await thread(from: response.thread) + } + + package func unarchiveThreadSnapshot(_ id: CodexThreadID) async throws -> CodexThreadSnapshot { + let response = try await sendUnarchiveThread(id) + let snapshot = Self.threadSnapshot(from: response.thread, includesTurns: false) + await router.seedTurns(snapshot.turns, threadID: id) + return snapshot + } + + private func sendUnarchiveThread( + _ id: CodexThreadID + ) async throws -> AppServerAPI.Thread.Unarchive.Response { + try await client.send( + AppServerAPI.Thread.Unarchive.Request( + params: .init(threadID: id.rawValue) + )) + } + + /// Archives a Codex thread. + /// + /// - Parameter id: The thread identifier to archive. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func archiveThread(_ id: CodexThreadID) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Archive.Request( + params: .init(threadID: id.rawValue) + )) + } + + /// Permanently deletes a Codex thread. + /// + /// - Parameter id: The thread identifier to delete. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func deleteThread(_ id: CodexThreadID) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: id.rawValue) + )) + } + + /// Lists Codex threads visible to the app-server account. + /// + /// - Parameter query: Paging and filtering options. + /// - Returns: A page of thread snapshots. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func listThreads(_ query: CodexThreadQuery = .init()) async throws -> CodexThreadPage { + let response = try await client.send( + AppServerAPI.Thread.List.Request( + params: .init( + archived: query.archived, + cursor: query.cursor, + cwd: query.workspaces.map { .paths($0.map(\.path)) }, + limit: query.limit, + modelProviders: query.modelProviders, + searchTerm: query.searchTerm, + sortDirection: query.sortDirection?.rawValue, + sortKey: query.sortKey?.rawValue, + sourceKinds: query.sourceKinds?.map(\.rawValue), + useStateDbOnly: query.useStateDBOnly + ))) + let snapshots = response.data.map { Self.threadSnapshot(from: $0, includesTurns: false) } + for snapshot in snapshots { + await router.seedTurns(snapshot.turns, threadID: snapshot.id) + } + return .init( + threads: snapshots, + nextCursor: response.nextCursor, + backwardsCursor: response.backwardsCursor + ) + } + + /// Lists available Codex models. + /// + /// - Parameter includeHidden: Whether hidden models should be included. + /// - Returns: The complete model list across all app-server result pages. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func models(includeHidden: Bool = false) async throws -> [CodexModel] { + var cursor: String? + var models: [CodexModel] = [] + repeat { + let response = try await client.send( + AppServerAPI.Model.List.Request( + params: .init(cursor: cursor, includeHidden: includeHidden) + )) + models.append(contentsOf: response.data) + cursor = response.nextCursor + } while cursor != nil + return models + } + + /// Reads the active Codex account. + /// + /// - Parameter refreshToken: Whether the app-server should refresh token state before returning. + /// - Returns: The active account, or `nil` when no account is signed in. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func account(refreshToken: Bool = false) async throws -> CodexAccount? { + let response = try await client.send( + AppServerAPI.Account.Read.Request(params: .init(refreshToken: refreshToken)) + ) + return response.account.map(Self.account) + } + + /// Reads the app-server configuration visible to Codex clients. + /// + /// - Returns: Model, reasoning, review model, and service-tier settings. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func configuration() async throws -> CodexConfiguration { + let response = try await client.send(AppServerAPI.Config.Read.Request()) + let reasoningEffort = response.config.modelReasoningEffort.map { + CodexReasoningEffort(rawValue: $0) + } + return .init( + model: response.config.model, + reviewModel: response.config.reviewModel, + reasoningEffort: reasoningEffort, + serviceTier: response.config.serviceTier + ) + } + + /// Applies a partial update to the app-server configuration. + /// + /// Fields left unchanged in the patch are not sent. Fields explicitly set + /// to `nil` are cleared in the app-server configuration. + /// + /// - Parameter patch: The configuration fields to update. + /// - Throws: A transport, JSON-RPC, or app-server configuration error. + public func updateConfiguration(_ patch: CodexConfigurationPatch) async throws { + var edits: [AppServerAPI.Config.Edit] = [] + if patch.updatesReviewModel { + edits.append(.init( + keyPath: "review_model", + value: patch.reviewModel.map(AppServerAPI.Config.Value.string) ?? .null + )) + } + if patch.updatesReasoningEffort { + edits.append(.init( + keyPath: "model_reasoning_effort", + value: patch.reasoningEffort.map { .string($0.rawValue) } ?? .null + )) + } + if patch.updatesServiceTier { + edits.append(.init( + keyPath: "service_tier", + value: patch.serviceTier.map(AppServerAPI.Config.Value.string) ?? .null + )) + } + guard edits.isEmpty == false else { + return + } + let _: AppServerAPI.Config.BatchWrite.Response = try await client.send( + AppServerAPI.Config.BatchWrite.Request(params: .init(edits: edits)) + ) + } + + /// Reads Codex account rate-limit information. + /// + /// - Returns: Current plan type and rate-limit windows reported by the app-server. + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func rateLimits() async throws -> CodexRateLimits { + let response = try await client.send(AppServerAPI.Account.RateLimits.Read.Request()) + await router.replaceRateLimits(with: response) + return .init(appServer: response) + } + + /// Starts a ChatGPT browser login flow. + /// + /// - Returns: A login handle containing the browser authentication URL. + /// - Throws: A transport, JSON-RPC, or app-server login error. + public func loginChatGPT( + accountReadinessTimeout: Duration? = nil + ) async throws -> CodexLoginHandle { + let state = try await loginRegistry.reserve( + readinessTimeout: accountReadinessTimeout, + cancel: { [client] id, deadline in + let response: AppServerAPI.Account.Login.Cancel.Response = try await client.send( + method: AppServerAPI.Account.Login.Cancel.Request.method, + params: AppServerAPI.Account.Login.Cancel.Params(loginID: id.rawValue), + responseType: AppServerAPI.Account.Login.Cancel.Response.self, + purpose: .operation(AppServerAPI.Account.Login.Cancel.Request.method), + deadline: deadline + ) + switch response.status { + case "canceled", "notFound": + return .cancelled + default: + throw CodexAppServerError.malformedNotification(.init( + method: "account/login/cancel response", + message: "Unknown cancel status \(response.status).", + rawData: nil + )) + } + }, + closeConnection: { [connectionLease] in + await connectionLease.closeConnection() + } + ) + do { + let response = try await client.send( + AppServerAPI.Account.Login.Start.Request(params: .chatGPT()), + onPostWriteCancellation: { [loginRegistry] response in + let (id, url) = try Self.chatGPTLoginIdentity(from: response) + let handle = try await loginRegistry.bind( + state, + id: id, + authenticationURL: url + ) + _ = try await handle.cancel() + } + ) + let (id, url) = try Self.chatGPTLoginIdentity(from: response) + return try await loginRegistry.bind(state, id: id, authenticationURL: url) + } catch { + await loginRegistry.abandon(state) + throw error + } + } + + /// Replaces the active credentials with an API key. + /// + /// A successful return means the app-server stored and reloaded the key in + /// its configured Codex home. It does not prove that a remote API request + /// will accept the key. + /// + /// - Parameter apiKey: A nonempty API key without leading or trailing whitespace. + /// - Throws: An input, transport, JSON-RPC, or app-server authentication error. + public func login(apiKey: String) async throws { + try Self.validate(apiKey: apiKey) + + let acceptedWriteHasUnknownOutcome = Mutex(false) + let response: AppServerAPI.Account.Login.Response + do { + response = try await client.send( + AppServerAPI.Account.Login.Start.Request(params: .apiKey(apiKey)), + onWriteAccepted: { + acceptedWriteHasUnknownOutcome.withLock { $0 = true } + }, + retriesOverloadResponses: false, + postWriteCallerCancellationPolicy: .returnResponse + ) + } catch is CancellationError { + guard acceptedWriteHasUnknownOutcome.withLock({ $0 }) else { + throw CancellationError() + } + throw CodexAppServerError.authenticationOutcomeUnknown(.transportEnded) + } catch let error as CodexAppServerError { + throw Self.apiKeyLoginError( + from: error, + acceptedWriteHasUnknownOutcome: acceptedWriteHasUnknownOutcome.withLock { $0 } + ) + } + + guard response == .apiKey else { + throw CodexAppServerError.authenticationOutcomeUnknown(.unexpectedResponse) + } + } + + /// Logs out of the active Codex account. + /// + /// - Throws: A transport, JSON-RPC, or app-server request error. + public func logout() async throws { + let _: EmptyResponse = try await client.send(AppServerAPI.Account.Logout.Request()) + } + + private func threadStartParams(options: CodexThread.Options) -> AppServerAPI.Thread.Start.Params { + .init( + model: options.model, + modelProvider: options.modelProvider, + ephemeral: options.ephemeral, + approvalPolicy: options.approvalMode?.approvalPolicy, + approvalsReviewer: options.approvalMode?.approvalsReviewer, + sandbox: options.sandbox?.threadSandboxValue, + serviceName: options.serviceName, + serviceTier: options.serviceTier, + personality: options.personality?.rawValue, + config: options.config?.mapValues(\.appServerJSONValue), + permissions: options.permissions?.appServerPermissions, + sessionStartSource: options.sessionStartSource?.appServerSource, + threadSource: options.threadSource?.appServerSource + ) + } + + private func thread( + from snapshot: AppServerAPI.Thread.Snapshot, + model: String? = nil + ) async -> CodexThread { + let threadID = CodexThreadID(rawValue: snapshot.id) + await router.seedTurns( + snapshot.turns.map(Self.turnSnapshots(from:)), + threadID: threadID + ) + return CodexThread( + id: threadID, + workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + model: model, + client: client, + router: router, + connectionLease: connectionLease + ) + } + + private func deleteThreadIgnoringCallerCancellation(_ id: CodexThreadID) async { + await Task { [self] in + try? await deleteThread(id) + }.value + } + + private func cleanupReviewIgnoringCallerCancellation(_ identity: CodexReviewIdentity) async { + _ = await Task { [self] in + await cleanupReview(identity) + }.value + } + + private nonisolated static func reviewCleanupIdentity( + for cancellation: CodexTurnCancellation, + sourceIdentity: CodexReviewIdentity, + model: String? + ) -> CodexReviewIdentity { + CodexReviewIdentity( + threadID: sourceIdentity.sourceThreadID, + turnID: cancellation.turnID ?? sourceIdentity.turnID, + reviewThreadID: cancellation.threadID == sourceIdentity.sourceThreadID ? nil : cancellation.threadID, + model: model ?? sourceIdentity.model + ) + } + + private nonisolated static func interruptLateReviewSession( + _ review: CodexReviewSession + ) async throws { + _ = try await review.interruptAndAwaitTerminalAcknowledgement() + } + + private nonisolated static func orderedReviewCleanupThreadIDs( + sourceThreadID: CodexThreadID, + sequences: [[CodexThreadID]] + ) -> [CodexThreadID] { + var seen: Set = [] + var threadIDs: [CodexThreadID] = [] + for sequence in sequences { + for threadID in sequence where threadID != sourceThreadID && seen.insert(threadID).inserted { + threadIDs.append(threadID) + } + } + if seen.insert(sourceThreadID).inserted { + threadIDs.append(sourceThreadID) + } + return threadIDs + } + + package nonisolated static func threadSnapshot( + from snapshot: AppServerAPI.Thread.Snapshot, + includesTurns: Bool + ) -> CodexThreadSnapshot { + let turns = turnSnapshots(from: snapshot.turns, includesTurns: includesTurns) + return .init( + id: .init(rawValue: snapshot.id), + workspace: snapshot.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + name: snapshot.name, + preview: snapshot.preview, + modelProvider: snapshot.modelProvider, + sessionID: snapshot.sessionID, + parentThreadID: snapshot.parentThreadID.map { .init(rawValue: $0) }, + source: snapshot.source.map(threadSessionSource(from:)), + gitInfo: snapshot.gitInfo.map { + CodexThreadGitInfo( + sha: $0.sha, + branch: $0.branch, + originURL: $0.originURL + ) + }, + createdAt: snapshot.createdAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + updatedAt: snapshot.updatedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + recencyAt: snapshot.recencyAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + status: snapshot.status.map { + CodexThreadStatus(type: $0.type, activeFlags: $0.activeFlags) + }, + ephemeral: snapshot.ephemeral, + turns: turns, + turnItemsAreAuthoritative: includesTurns, + presentFields: threadSnapshotPresentFields(from: snapshot, turns: turns) + ) + } + + private nonisolated static func threadSnapshotPresentFields( + from snapshot: AppServerAPI.Thread.Snapshot, + turns: [CodexTurnSnapshot]? + ) -> Set { + var fields: Set = [] + for field in snapshot.presentFields { + switch field { + case .sessionID: + fields.insert(.sessionID) + case .parentThreadID: + fields.insert(.parentThreadID) + case .cwd: + fields.insert(.workspace) + case .name: + fields.insert(.name) + case .preview: + fields.insert(.preview) + case .modelProvider: + fields.insert(.modelProvider) + case .source: + fields.insert(.source) + case .gitInfo: + fields.insert(.gitInfo) + case .createdAt: + fields.insert(.createdAt) + case .updatedAt: + fields.insert(.updatedAt) + case .recencyAt: + fields.insert(.recencyAt) + case .status: + fields.insert(.status) + case .ephemeral: + fields.insert(.ephemeral) + case .turns: + if turns != nil { + fields.insert(.turns) + } + } + } + if turns != nil { + fields.insert(.turns) + } + return fields + } + + private nonisolated static func threadSessionSource( + from source: AppServerAPI.Thread.SessionSource + ) -> CodexThreadSessionSource { + switch source { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .custom(let value): + .custom(value) + case .subAgent(let source): + .subAgent(threadSubAgentSource(from: source)) + case .unknown: + .unknown + } + } + + private nonisolated static func threadSubAgentSource( + from source: AppServerAPI.Thread.SessionSource.SubAgent + ) -> CodexThreadSessionSource.SubAgent { + switch source { + case .review: + .review + case .compact: + .compact + case .threadSpawn(let spawn): + .threadSpawn(.init( + parentThreadID: .init(rawValue: spawn.parentThreadID), + depth: spawn.depth, + agentPath: spawn.agentPath, + agentNickname: spawn.agentNickname, + agentRole: spawn.agentRole + )) + case .memoryConsolidation: + .memoryConsolidation + case .other(let value): + .other(value) + } + } + + package nonisolated static func turnSnapshots( + from turns: [AppServerAPI.Turn.Payload] + ) -> [CodexTurnSnapshot] { + turns.map { + let status = CodexTurnStatus(rawValue: $0.status) + let state: CodexTurnSnapshot.State = switch status { + case .inProgress: + .inProgress + case .completed: + .completed + case .interrupted: + .interrupted + case .failed: + .failed(Self.requiredTurnError(from: $0)) + case .unknown(let rawValue): + .unknown(rawValue: rawValue, error: $0.error.map(Self.turnError(from:))) + } + return CodexTurnSnapshot( + id: .init(rawValue: $0.id), + state: state, + itemsLoadState: $0.itemsLoadState ?? ($0.items == nil ? .notLoaded : .full), + items: AppServerThreadItemMapping.threadItems(from: $0.items), + startedAt: $0.startedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + completedAt: $0.completedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + duration: $0.durationMS.map { .milliseconds(Int64($0)) } + ) + } + } + + private nonisolated static func requiredTurnError( + from turn: AppServerAPI.Turn.Payload + ) -> CodexTurnError { + guard let error = turn.error else { + preconditionFailure("Strict Turn.Payload decoding requires failed turns to carry an error.") + } + return turnError(from: error) + } + + package nonisolated static func turnError( + from error: AppServerAPI.Turn.Error + ) -> CodexTurnError { + .init( + message: error.message, + info: error.codexErrorInfo.map(Self.errorInfo(from:)), + additionalDetails: error.additionalDetails + ) + } + + private nonisolated static func errorInfo( + from info: AppServerAPI.CodexErrorInfo + ) -> CodexErrorInfo { + switch info { + case .contextWindowExceeded: .contextWindowExceeded + case .sessionBudgetExceeded: .sessionBudgetExceeded + case .usageLimitExceeded: .usageLimitExceeded + case .serverOverloaded: .serverOverloaded + case .cyberPolicy: .cyberPolicy + case .httpConnectionFailed(let status): .httpConnectionFailed(httpStatusCode: status) + case .responseStreamConnectionFailed(let status): + .responseStreamConnectionFailed(httpStatusCode: status) + case .internalServerError: .internalServerError + case .unauthorized: .unauthorized + case .badRequest: .badRequest + case .threadRollbackFailed: .threadRollbackFailed + case .sandboxError: .sandboxError + case .responseStreamDisconnected(let status): + .responseStreamDisconnected(httpStatusCode: status) + case .responseTooManyFailedAttempts(let status): + .responseTooManyFailedAttempts(httpStatusCode: status) + case .activeTurnNotSteerable(let kind): .activeTurnNotSteerable(turnKind: kind) + case .other: .other + case .unknown(let rawValue): .unknown(rawValue: rawValue) + } + } + + private nonisolated static func turnSnapshots( + from turns: [AppServerAPI.Turn.Payload]?, + includesTurns: Bool + ) -> [CodexTurnSnapshot]? { + guard let turns else { + return includesTurns ? [] : nil + } + guard includesTurns || turns.isEmpty == false else { + return nil + } + return turnSnapshots(from: turns) + } + + package nonisolated static func account( + from snapshot: AppServerAPI.Account.Snapshot + ) -> CodexAccount { + .init( + id: snapshot.id, + kind: .init(rawValue: snapshot.kind.rawValue) ?? .chatGPT, + label: snapshot.label, + planType: snapshot.planType + ) + } + + private nonisolated static func chatGPTLoginIdentity( + from response: AppServerAPI.Account.Login.Response + ) throws -> (CodexLoginHandle.ID, URL) { + guard case .chatgpt(let loginID, let authURL) = response else { + throw CodexAppServerError.malformedNotification(.init( + method: "account/login/start response", + message: "Expected ChatGPT login response.", + rawData: nil + )) + } + guard let url = URL(string: authURL) else { + throw CodexAppServerError.malformedNotification(.init( + method: "account/login/start response", + message: "Invalid ChatGPT authentication URL.", + rawData: nil + )) + } + return (.init(rawValue: loginID), url) + } + + private nonisolated static func validate(apiKey: String) throws { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { + throw CodexAppServerError.invalidAPIKey(.empty) + } + guard trimmed == apiKey else { + throw CodexAppServerError.invalidAPIKey(.surroundingWhitespace) + } + } + + private nonisolated static func apiKeyLoginError( + from error: CodexAppServerError, + acceptedWriteHasUnknownOutcome: Bool + ) -> CodexAppServerError { + if case .request(let failure) = error { + switch failure.kind { + case .server, .overloadRetryExhausted: + return .request(sanitizedAPIKeyLoginRequestFailure(failure)) + case .deadlineExceeded(let duration) where acceptedWriteHasUnknownOutcome: + return .authenticationOutcomeUnknown(.deadlineExceeded(duration)) + case .invalidResponse where acceptedWriteHasUnknownOutcome: + return .authenticationOutcomeUnknown(.invalidResponse) + case .write where acceptedWriteHasUnknownOutcome, + .transport where acceptedWriteHasUnknownOutcome: + return .authenticationOutcomeUnknown(.transportEnded) + case .encode, .write, .transport, .invalidResponse, .deadlineExceeded: + return .request(sanitizedAPIKeyLoginRequestFailure(failure)) + } + } + if case .connectionTerminated = error, acceptedWriteHasUnknownOutcome { + return .authenticationOutcomeUnknown(.connectionTerminated) + } + if case .connectionTerminated(let termination) = error { + return .connectionTerminated(sanitizedAPIKeyLoginTermination(termination)) + } + return error + } + + private nonisolated static func sanitizedAPIKeyLoginRequestFailure( + _ failure: CodexRequestFailure + ) -> CodexRequestFailure { + let kind: CodexRequestFailure.Kind = switch failure.kind { + case .encode: + .encode(message: "API-key login request encoding failed.") + case .write(let transportFailure): + .write(sanitizedAPIKeyLoginTransportFailure(transportFailure)) + case .transport(let transportFailure): + .transport(sanitizedAPIKeyLoginTransportFailure(transportFailure)) + case .server(let serverError): + .server(.init( + code: serverError.code, + message: "API-key login was rejected by the app-server." + )) + case .invalidResponse(let expectedType, _, _): + .invalidResponse( + expectedType: expectedType, + message: "The app-server returned an invalid API-key login response.", + rawData: nil + ) + case .deadlineExceeded(let duration): + .deadlineExceeded(duration) + case .overloadRetryExhausted(let serverError, let attempts): + .overloadRetryExhausted( + last: .init( + code: serverError.code, + message: "The app-server remained overloaded." + ), + attempts: attempts + ) + } + return .init( + requestID: failure.requestID, + method: failure.method, + purpose: failure.purpose, + kind: kind + ) + } + + private nonisolated static func sanitizedAPIKeyLoginTermination( + _ termination: CodexConnectionTermination + ) -> CodexConnectionTermination { + switch termination { + case .closedByCaller: + .closedByCaller + case .processExited(let status): + .processExited(status: status) + case .transportFailure(let failure): + .transportFailure(sanitizedAPIKeyLoginTransportFailure(failure)) + } + } + + private nonisolated static func sanitizedAPIKeyLoginTransportFailure( + _ failure: CodexTransportFailure + ) -> CodexTransportFailure { + switch failure { + case .closed: + .closed + case .io(let errno, _): + .io(errno: errno, message: "The API-key login transport failed.") + case .framing: + .framing(message: "The API-key login transport returned an invalid frame.", rawData: nil) + case .protocolViolation: + .protocolViolation( + message: "The API-key login transport violated the app-server protocol.", + rawData: nil + ) + case .contractViolation: + .contractViolation(message: "The API-key login transport contract was violated.") + } + } + +} diff --git a/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift new file mode 100644 index 00000000..54cb5b1b --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift @@ -0,0 +1,1301 @@ +import Foundation +import OSLog + +private let notificationRouterLogger = Logger( + subsystem: "CodexAppServerKit", + category: "notification-router" +) + +package actor CodexAppServerNotificationRouter { + private typealias NotificationContext = AppServerNotificationDecoder.Context + + private enum Phase { + case open + case terminating(CodexAppServerError) + case terminated(CodexAppServerError) + + var terminationError: CodexAppServerError? { + switch self { + case .open: + nil + case .terminating(let error), .terminated(let error): + error + } + } + } + + package struct DetachedReviewRoutingSnapshot: Equatable, Sendable { + package var attemptCount: Int + package var candidateCount: Int + package var drainCount: Int + } + + private struct DetachedReviewRoutingAttempt { + var candidateThreadIDs: Set = [] + } + + // A detached review's response is the first value that identifies its fresh event thread. + // Keep otherwise-unowned notifications intact until that request attempt can claim them; + // classifying a turn/started by arrival order discards unrelated concurrent work. The + // upstream fresh-thread contract means a later attempt cannot reuse an existing candidate. + private struct DetachedReviewRoutingCandidate { + var waitingAttempts: Set + var notifications: [AppServerNotificationDecoder.DecodedNotification] = [] + } + + private enum NotificationDrainMode { + case claimedReview(CodexTurnID) + case unclaimed + } + + private struct NotificationDrain { + var queuedNotifications: [AppServerNotificationDecoder.DecodedNotification] = [] + } + + private var threadIDByTurnID: [CodexTurnID: CodexThreadID] = [:] + private var detachedReviewAttempts: [ + TurnReplayPendingToken: DetachedReviewRoutingAttempt + ] = [:] + private var detachedReviewCandidatesByThreadID: [ + CodexThreadID: DetachedReviewRoutingCandidate + ] = [:] + private var notificationDrainsByThreadID: [CodexThreadID: NotificationDrain] = [:] + private var notificationDrainPauseForTesting: (@Sendable () async -> Void)? + private var terminalReplayPublicationPauseForTesting: (@Sendable () async -> Void)? + private var phase = Phase.open + private var itemReducer = CodexItemReducer() + private let accountEventHub: AccountEventHub + package nonisolated let loginRegistry: LoginRegistry + package nonisolated let turnReplayStore: TurnReplayStore + package nonisolated let threadEventHub: ThreadEventHub + + package init( + client: AppServerClient, + turnReplayStore: TurnReplayStore, + threadEventHub: ThreadEventHub, + accountEventHub: AccountEventHub = .init(), + loginRegistry: LoginRegistry = .init() + ) { + _ = client + self.turnReplayStore = turnReplayStore + self.threadEventHub = threadEventHub + self.accountEventHub = accountEventHub + self.loginRegistry = loginRegistry + } + + package nonisolated func events(for threadID: CodexThreadID) -> CodexThreadEventSequence { + threadEventHub.events(for: threadID) + } + + package func seedTurn(_ turnID: CodexTurnID, threadID: CodexThreadID) { + if let existingThreadID = installTurnAssociation(turnID, threadID: threadID) { + precondition( + existingThreadID == threadID, + "A turn cannot move between thread associations." + ) + } + } + + private func installTurnAssociation( + _ turnID: CodexTurnID, + threadID: CodexThreadID + ) -> CodexThreadID? { + if let existingThreadID = threadIDByTurnID[turnID] { + return existingThreadID + } + threadIDByTurnID[turnID] = threadID + return nil + } + + private func associateNotificationTurn( + _ turnID: CodexTurnID, + threadID: CodexThreadID + ) throws { + guard let existingThreadID = installTurnAssociation(turnID, threadID: threadID) else { + return + } + guard existingThreadID == threadID else { + throw CodexTransportFailure.contractViolation( + message: "Turn \(turnID.rawValue) is already associated with thread " + + "\(existingThreadID.rawValue) and cannot move to thread " + + "\(threadID.rawValue)." + ) + } + } + + package func discardTurnAssociation( + _ turnID: CodexTurnID, + threadID: CodexThreadID + ) { + guard let registeredThreadID = threadIDByTurnID[turnID] else { + return + } + precondition( + registeredThreadID == threadID, + "Only the thread that owns a turn association may discard it." + ) + threadIDByTurnID.removeValue(forKey: turnID) + } + + package func seedTurns( + _ turns: [CodexTurnSnapshot]?, + threadID: CodexThreadID + ) { + itemReducer.seed(turns) + for turn in turns ?? [] { + seedTurn(turn.id, threadID: threadID) + } + } + + package func seedCurrentTurnSnapshot( + _ snapshot: CodexTurnSnapshot, + threadID: CodexThreadID + ) { + threadEventHub.seedCurrentTurnSnapshot(snapshot, for: threadID) + } + + package func accountEvents() async -> CodexAccountEvents { + return await accountEventHub.events() + } + + package func replaceRateLimits( + with response: AppServerAPI.Account.RateLimits.Response + ) async { + await accountEventHub.replaceRateLimits(with: response) + } + package nonisolated func threadSubscriberCountForTesting(for threadID: CodexThreadID) -> Int { + threadEventHub.snapshotForTesting(threadID: threadID).subscriberCount + } + + package func detachedReviewRoutingSnapshotForTesting() -> DetachedReviewRoutingSnapshot { + .init( + attemptCount: detachedReviewAttempts.count, + candidateCount: detachedReviewCandidatesByThreadID.count, + drainCount: notificationDrainsByThreadID.count + ) + } + + package func itemSnapshotForTesting( + turnID: CodexTurnID, + itemID: String + ) -> CodexThreadItem? { + itemReducer.item(turnID: turnID, itemID: itemID) + } + + package nonisolated func resetThreadEventGeneration(_ threadID: CodexThreadID) { + threadEventHub.resetGeneration(for: threadID) + } + + package func adoptThreadEventGeneration( + _ threadID: CodexThreadID, + including turnID: CodexTurnID + ) { + threadEventHub.beginGeneration(for: threadID, including: turnID) + seedTurn(turnID, threadID: threadID) + } + + package func registerDetachedReviewRoutingAttempt( + _ pending: TurnReplayPendingToken + ) { + precondition( + detachedReviewAttempts[pending] == nil, + "A detached review routing attempt may be registered exactly once." + ) + detachedReviewAttempts[pending] = .init() + } + + package func reconcileReviewStartResponse( + _ pending: TurnReplayPendingToken, + reviewThreadID: CodexThreadID, + initialSnapshot: CodexTurnSnapshot, + generation: ThreadEventGenerationAttempt + ) async throws { + try requireOpen() + await turnReplayStore.bind( + pending, + to: initialSnapshot.id, + initialSnapshot: initialSnapshot + ) + try requireOpen() + try generation.resolveReviewStartResponse( + eventThreadID: reviewThreadID, + responseSnapshot: initialSnapshot + ) + seedTurn(initialSnapshot.id, threadID: reviewThreadID) + try await resolveDetachedReviewRoutingAttempt( + pending, + reviewThreadID: reviewThreadID, + turnID: initialSnapshot.id + ) + try requireOpen() + } + + package func rejectDetachedReviewRoutingAttemptResponse( + _ pending: TurnReplayPendingToken + ) async throws { + try requireOpen() + guard var attempt = detachedReviewAttempts[pending] else { + return + } + var candidatesToDrain: [ + (CodexThreadID, DetachedReviewRoutingCandidate, NotificationDrainMode) + ] = [] + for threadID in attempt.candidateThreadIDs { + guard var candidate = detachedReviewCandidatesByThreadID[threadID] else { + continue + } + candidate.waitingAttempts.remove(pending) + if candidate.waitingAttempts.isEmpty { + detachedReviewCandidatesByThreadID.removeValue(forKey: threadID) + candidatesToDrain.append((threadID, candidate, .unclaimed)) + } else { + detachedReviewCandidatesByThreadID[threadID] = candidate + } + } + attempt.candidateThreadIDs.removeAll(keepingCapacity: true) + detachedReviewAttempts[pending] = attempt + try await drainCandidates(candidatesToDrain) + try requireOpen() + } + + package func cancelDetachedReviewRoutingAttempt( + _ pending: TurnReplayPendingToken + ) async throws { + guard let attempt = detachedReviewAttempts.removeValue(forKey: pending) else { + return + } + var candidatesToDrain: [ + (CodexThreadID, DetachedReviewRoutingCandidate, NotificationDrainMode) + ] = [] + for threadID in attempt.candidateThreadIDs { + guard var candidate = detachedReviewCandidatesByThreadID[threadID] else { + continue + } + candidate.waitingAttempts.remove(pending) + if candidate.waitingAttempts.isEmpty { + detachedReviewCandidatesByThreadID.removeValue(forKey: threadID) + candidatesToDrain.append(( + threadID, + candidate, + .unclaimed + )) + } else { + detachedReviewCandidatesByThreadID[threadID] = candidate + } + } + try await drainCandidates(candidatesToDrain) + } + + private func resolveDetachedReviewRoutingAttempt( + _ pending: TurnReplayPendingToken, + reviewThreadID: CodexThreadID, + turnID: CodexTurnID + ) async throws { + guard let attempt = detachedReviewAttempts.removeValue(forKey: pending) else { + return + } + var candidatesToDrain: [ + (CodexThreadID, DetachedReviewRoutingCandidate, NotificationDrainMode) + ] = [] + for candidateThreadID in attempt.candidateThreadIDs { + guard var candidate = detachedReviewCandidatesByThreadID[candidateThreadID] else { + continue + } + if candidateThreadID == reviewThreadID { + detachedReviewCandidatesByThreadID.removeValue(forKey: candidateThreadID) + for waitingAttempt in candidate.waitingAttempts where waitingAttempt != pending { + detachedReviewAttempts[waitingAttempt]?.candidateThreadIDs.remove( + candidateThreadID + ) + } + candidatesToDrain.append(( + candidateThreadID, + candidate, + .claimedReview(turnID) + )) + continue + } + + candidate.waitingAttempts.remove(pending) + if candidate.waitingAttempts.isEmpty { + detachedReviewCandidatesByThreadID.removeValue(forKey: candidateThreadID) + candidatesToDrain.append(( + candidateThreadID, + candidate, + .unclaimed + )) + } else { + detachedReviewCandidatesByThreadID[candidateThreadID] = candidate + } + } + try await drainCandidates(candidatesToDrain) + } + + private func drainCandidates( + _ candidates: [( + CodexThreadID, + DetachedReviewRoutingCandidate, + NotificationDrainMode + )] + ) async throws { + try requireOpen() + beginNotificationDrains(candidates) + do { + for (threadID, candidate, mode) in candidates { + try await drainNotifications( + candidate.notifications, + threadID: threadID, + mode: mode + ) + } + } catch { + for (threadID, _, _) in candidates { + notificationDrainsByThreadID.removeValue(forKey: threadID) + } + throw error + } + } + + private func beginNotificationDrains( + _ candidates: [( + CodexThreadID, + DetachedReviewRoutingCandidate, + NotificationDrainMode + )] + ) { + for (threadID, _, _) in candidates { + precondition( + notificationDrainsByThreadID[threadID] == nil, + "A thread may drain only one notification sequence at a time." + ) + notificationDrainsByThreadID[threadID] = .init() + } + } + + private func drainNotifications( + _ initialNotifications: [AppServerNotificationDecoder.DecodedNotification], + threadID: CodexThreadID, + mode: NotificationDrainMode + ) async throws { + guard phase.terminationError == nil else { + return + } + guard notificationDrainsByThreadID[threadID] != nil else { + preconditionFailure("A notification drain must install its routing gate first.") + } + if let notificationDrainPauseForTesting { + await notificationDrainPauseForTesting() + } + defer { + notificationDrainsByThreadID.removeValue(forKey: threadID) + } + var notifications = initialNotifications + while true { + for notification in notifications { + guard phase.terminationError == nil else { + return + } + try await routeDrainedNotification(notification, mode: mode) + } + guard phase.terminationError == nil else { + return + } + guard var drain = notificationDrainsByThreadID[threadID] else { + preconditionFailure("An active notification drain lost its routing gate.") + } + if drain.queuedNotifications.isEmpty { + return + } + notifications = drain.queuedNotifications + drain.queuedNotifications.removeAll(keepingCapacity: true) + notificationDrainsByThreadID[threadID] = drain + } + } + + private func routeDrainedNotification( + _ notification: AppServerNotificationDecoder.DecodedNotification, + mode: NotificationDrainMode + ) async throws { + try requireOpen() + if case .claimedReview(let turnID) = mode, + case .turnStarted(let startedTurnID) = notification.payload, + (notification.context.turnID ?? startedTurnID) != turnID { + return + } + if let threadID = notification.context.threadID, + let turnID = notification.context.turnID { + try associateNotificationTurn(turnID, threadID: threadID) + } + let replayRouting: ReplayRouting + switch mode { + case .claimedReview: + replayRouting = .boundOnly + case .unclaimed: + replayRouting = .boundOnly + } + try await routeNotification( + notification, + replayRouting: replayRouting + ) + } + + package func route( + _ decoded: AppServerNotificationDecoder.DecodedNotification + ) async throws { + guard phase.terminationError == nil else { + return + } + guard decoded.disposition != .explicitIgnore else { + return + } + + var context = decoded.context + if context.threadID == nil, let turnID = context.turnID { + context.threadID = threadIDByTurnID[turnID] + ?? detachedReviewCandidatesByThreadID.first { _, candidate in + candidate.notifications.contains { $0.context.turnID == turnID } + }?.key + } + var routed = decoded + routed.context = context + var replayRouting = ReplayRouting.unboundAllowed + + if let threadID = context.threadID, + var drain = notificationDrainsByThreadID[threadID] { + drain.queuedNotifications.append(routed) + notificationDrainsByThreadID[threadID] = drain + return + } + + if let threadID = context.threadID { + if var candidate = detachedReviewCandidatesByThreadID[threadID] { + candidate.notifications.append(routed) + detachedReviewCandidatesByThreadID[threadID] = candidate + return + } + + let localDisposition = threadEventHub.turnStartDisposition(for: threadID) + if let turnID = context.turnID, case .turnStarted = decoded.payload { + switch await reviewSubturnStartDisposition(turnID, threadID: threadID) { + case .route: + break + case .suppress: + return + case .deferUntilOwned: + break + } + } + + let hasWriteAcceptedNonDetachedOperation = await turnReplayStore + .hasWriteAcceptedNonDetachedOperation(for: threadID) + let waitingAttempts = activeDetachedReviewRoutingAttempts() + if waitingAttempts.isEmpty == false, + hasWriteAcceptedNonDetachedOperation == false { + replayRouting = .boundOnly + } + let hasKnownOwner = context.turnID.flatMap { threadIDByTurnID[$0] } != nil + || localDisposition != .deferUntilOwned + || hasWriteAcceptedNonDetachedOperation + if hasKnownOwner == false { + if waitingAttempts.isEmpty == false { + bufferNotification( + routed, + threadID: threadID, + waitingAttempts: waitingAttempts + ) + return + } + } + } else if activeDetachedReviewRoutingAttempts().isEmpty == false { + replayRouting = .boundOnly + } + if let threadID = context.threadID, let turnID = context.turnID { + try associateNotificationTurn(turnID, threadID: threadID) + } + try await routeNotification(routed, replayRouting: replayRouting) + } + + private enum ReplayRouting { + case unboundAllowed + case boundOnly + } + + private func reviewSubturnStartDisposition( + _ turnID: CodexTurnID, + threadID: CodexThreadID + ) async -> ThreadEventTurnStartDisposition { + // Codex reviews can forward the internal reviewer's turn/started with a child ID + // while every substantive item and terminal belongs to the response's outer turn. + // Only that advisory start is ignored; other cross-turn events remain fail-fast. + let associatedTurnIDs = threadIDByTurnID.compactMap { associatedTurnID, associatedThreadID in + associatedThreadID == threadID && associatedTurnID != turnID ? associatedTurnID : nil + } + for associatedTurnID in associatedTurnIDs { + if await turnReplayStore.isActiveReviewGeneration(associatedTurnID) { + return .suppress + } + } + return threadEventHub.turnStartDisposition(for: threadID) + } + + private func activeDetachedReviewRoutingAttempts() -> Set { + Set(detachedReviewAttempts.keys.filter(\.isWriteAccepted)) + } + + private func bufferNotification( + _ notification: AppServerNotificationDecoder.DecodedNotification, + threadID: CodexThreadID, + waitingAttempts: Set + ) { + detachedReviewCandidatesByThreadID[threadID] = .init( + waitingAttempts: waitingAttempts, + notifications: [notification] + ) + for pending in waitingAttempts { + detachedReviewAttempts[pending]?.candidateThreadIDs.insert(threadID) + } + } + + private func routeNotification( + _ notification: AppServerNotificationDecoder.DecodedNotification, + replayRouting: ReplayRouting = .unboundAllowed + ) async throws { + let context = notification.context + switch notification.payload { + case .turnCompleted(let turn): + let outcome = try terminalOutcome(from: turn, context: context) + let turnID = context.turnID ?? outcome.response.turnID + if let threadID = context.threadID ?? threadIDByTurnID[turnID] { + try routeThreadEvent(.terminal(outcome), threadID: threadID) + } + threadIDByTurnID.removeValue(forKey: turnID) + itemReducer.release(turnID: turnID) + if let terminalReplayPublicationPauseForTesting { + await terminalReplayPublicationPauseForTesting() + } + await finishReplay( + outcome, + replayRouting: replayRouting + ) + + case .item(let mutation): + guard let turnID = context.turnID else { + preconditionFailure("Validated item notification lost turnId.") + } + let event = try itemReducer.reduce(mutation, turnID: turnID) + if let threadID = context.threadID ?? threadIDByTurnID[turnID] { + try routeThreadEvent( + Self.threadEvent(from: event, turnID: turnID, threadID: threadID), + threadID: threadID + ) + } + await routeReplay( + event, + turnID: turnID, + replayRouting: replayRouting + ) + + case .turnStarted(let payloadTurnID): + let turnID = context.turnID ?? payloadTurnID + if let threadID = context.threadID ?? threadIDByTurnID[turnID] { + try routeThreadEvent(.turnStarted(turnID), threadID: threadID) + } + let event = CodexTurnEvent.started(turnID) + await routeReplay( + event, + turnID: turnID, + replayRouting: replayRouting + ) + + case .threadStatus(let status): + if let threadID = context.threadID { + try routeThreadEvent(.statusChanged(status), threadID: threadID) + } + + case .tokenUsage(let usage): + if let threadID = context.threadID { + try routeThreadEvent( + .tokenUsageUpdated(usage, turnID: context.turnID), + threadID: threadID + ) + } + if let turnID = context.turnID { + let event = CodexTurnEvent.tokenUsageUpdated(usage) + await routeReplay( + event, + turnID: turnID, + replayRouting: replayRouting + ) + } + + case .threadClosed: + if let threadID = context.threadID { + try routeThreadEvent(.closed, threadID: threadID) + } + + case .serverRequestResolved, .connectionDiagnostic: + preconditionFailure("A connection-owned notification reached the domain router.") + + case .account(let mutation): + switch mutation { + case .updated(let update): + await loginRegistry.applyAccountUpdate(update) + await accountEventHub.apply(.updated(update)) + case .rateLimitsUpdated(let update): + await accountEventHub.apply(.rateLimitsUpdated(update)) + case .loginCompleted(let completion): + await loginRegistry.apply(completion) + } + + case .raw: + let raw = CodexRawNotification( + method: notification.methodName, + params: notification.rawData, + threadID: context.threadID, + turnID: context.turnID + ) + if let threadID = context.threadID { + try routeThreadEvent(.unknown(raw), threadID: threadID) + } + if let turnID = context.turnID { + let event = CodexTurnEvent.unknown(raw) + await routeReplay( + event, + turnID: turnID, + replayRouting: replayRouting + ) + } + + case .ignored: + preconditionFailure("Explicit-ignore notification reached the router.") + } + } + + private func routeReplay( + _ event: CodexTurnEvent, + turnID: CodexTurnID, + replayRouting: ReplayRouting + ) async { + switch replayRouting { + case .unboundAllowed: + recordReplayDisposition( + await turnReplayStore.routeIfTracked(event, for: turnID), + turnID: turnID + ) + case .boundOnly: + recordReplayDisposition( + await turnReplayStore.routeIfTracked( + event, + for: turnID, + allowsOrphanGeneration: false + ), + turnID: turnID + ) + } + } + + private func finishReplay( + _ outcome: CodexTurnOutcome, + replayRouting: ReplayRouting + ) async { + switch replayRouting { + case .unboundAllowed: + _ = await turnReplayStore.finishIfTracked(outcome) + case .boundOnly: + _ = await turnReplayStore.finishIfTracked( + outcome, + allowsOrphanGeneration: false + ) + } + } + + private func routeThreadEvent(_ event: CodexThreadEvent, threadID: CodexThreadID) throws { + let overflowCount = try threadEventHub.route(event, for: threadID) + if overflowCount > 0 { + notificationRouterLogger.warning( + "Compacted \(overflowCount, privacy: .public) slow thread event subscriber(s) for \(threadID.rawValue, privacy: .public)" + ) + } + if case .closed = event { + let turnIDs = threadIDByTurnID.compactMap { entry in + entry.value == threadID ? entry.key : nil + } + for turnID in turnIDs { + itemReducer.release(turnID: turnID) + threadIDByTurnID.removeValue(forKey: turnID) + } + } + } + + private nonisolated static func threadEvent( + from event: CodexTurnEvent, + turnID: CodexTurnID, + threadID: CodexThreadID + ) -> CodexThreadEvent { + switch event { + case .started(let turnID): + return .turnStarted(turnID) + case .snapshot(let snapshot): + return .snapshot(snapshot) + case .itemStarted(let item): + return .itemStarted(item, turnID: turnID) + case .itemUpdated(let item): + return .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item): + return .itemCompleted(item, turnID: turnID) + case .message(let message): + return .message(message, turnID: turnID) + case .messageDelta(let delta): + return .messageDelta(delta, turnID: turnID) + case .reasoningSummaryPartAdded(let part): + return .reasoningSummaryPartAdded(part, turnID: turnID) + case .reasoningDelta(let delta): + return .reasoningDelta(delta, turnID: turnID) + case .diagnostic(let diagnostic): + return .diagnostic(diagnostic, turnID: turnID) + case .tokenUsageUpdated(let usage): + return .tokenUsageUpdated(usage, turnID: turnID) + case .terminal(let outcome): + return .terminal(outcome) + case .unknown(let raw): + var raw = raw + raw.threadID = threadID + raw.turnID = turnID + return .unknown(raw) + } + } + + package func finishAll(with termination: CodexConnectionTermination) async { + let error = CodexAppServerError.connectionTerminated(termination) + guard case .open = phase else { + return + } + phase = .terminating(error) + detachedReviewAttempts.removeAll(keepingCapacity: false) + detachedReviewCandidatesByThreadID.removeAll(keepingCapacity: false) + threadIDByTurnID.removeAll(keepingCapacity: false) + itemReducer.releaseAll() + threadEventHub.finish(throwing: error) + await turnReplayStore.terminateAll(with: termination) + await accountEventHub.finish(throwing: error) + await loginRegistry.finish(throwing: error) + notificationDrainsByThreadID.removeAll(keepingCapacity: false) + phase = .terminated(error) + } + + package func finishLogin(throwing error: CodexAppServerError) async { + await loginRegistry.finish(throwing: error) + } + + package func setNotificationDrainPauseForTesting( + _ pause: (@Sendable () async -> Void)? + ) { + notificationDrainPauseForTesting = pause + } + + package func setTerminalReplayPublicationPauseForTesting( + _ pause: (@Sendable () async -> Void)? + ) { + terminalReplayPublicationPauseForTesting = pause + } + + package func turnAssociationForTesting( + _ turnID: CodexTurnID + ) -> CodexThreadID? { + threadIDByTurnID[turnID] + } + + private nonisolated func recordReplayDisposition( + _ disposition: TurnReplayStore.RoutingDisposition, + turnID: CodexTurnID + ) { + guard case .routed(let count) = disposition else { + return + } + guard count > 0 else { + return + } + notificationRouterLogger.warning( + "Compacted \(count, privacy: .public) slow turn replay subscriber(s) for \(turnID.rawValue, privacy: .public)" + ) + } + + private func requireOpen() throws { + if let error = phase.terminationError { + throw error + } + } + + private func terminalOutcome( + from turn: AppServerAPI.Turn.Payload, + context: NotificationContext + ) throws -> CodexTurnOutcome { + let turnID = CodexTurnID(rawValue: turn.id) + if let correlatedTurnID = context.turnID, correlatedTurnID != turnID { + throw CodexAppServerError.malformedNotification(.init( + method: "turn/completed", + message: "Correlated turn id \(correlatedTurnID.rawValue) does not match payload turn id \(turnID.rawValue).", + rawData: nil + )) + } + let snapshot = CodexAppServer.turnSnapshots(from: [turn])[0] + // A terminal notification may carry only a sparse summary; without + // itemsView, omissions cannot be treated as authoritative. + let transcriptItemsLoadState = turn.itemsLoadState ?? .notLoaded + let response = CodexResponse( + turnID: snapshot.id, + transcript: .init(items: snapshot.items), + transcriptItemsLoadState: transcriptItemsLoadState, + startedAt: snapshot.startedAt, + completedAt: snapshot.completedAt, + duration: snapshot.duration + ) + switch snapshot.state { + case .completed: + return .completed(response) + case .interrupted: + return .interrupted(response) + case .failed(let error): + return .failed(.init(response: response, error: error)) + case .inProgress: + return .invalidTerminalStatus( + rawStatus: CodexTurnStatus.inProgress.rawValue, + error: nil, + response: response + ) + case .unknown(let rawValue, let error): + return .invalidTerminalStatus( + rawStatus: rawValue, + error: error, + response: response + ) + } + } + +} + +package enum AppServerThreadItemMapping { + package static func threadItems(from values: [AppServerJSONValue]?) -> [CodexThreadItem] { + values?.compactMap(threadItem(from:)) ?? [] + } + + package static func threadItem(from value: AppServerJSONValue) -> CodexThreadItem? { + guard let data = try? JSONEncoder().encode(value), + let item = try? JSONDecoder().decode(RawThreadItem.self, from: data) + else { + return nil + } + return item.makeThreadItem(startedAt: nil, completedAt: nil, allowsFallbackID: true) + } +} + +struct RawCommandAction: Decodable { + var kind: String + var command: String? + var name: String? + var path: String? + var query: String? + + var codexCommandAction: CodexCommand.Action { + CodexCommand.Action( + kind: codexKind, + command: command, + name: name, + path: path, + query: query + ) + } + + private var codexKind: CodexCommand.Action.Kind { + switch kind { + case "read": + .read + case "listFiles", "list_files": + .listFiles + case "search": + .search + default: + .unknown + } + } + + enum CodingKeys: String, CodingKey { + case type + case kind + case command + case name + case path + case query + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = (try? container.decodeStringIfPresent(forKey: .type)) + ?? (try? container.decodeStringIfPresent(forKey: .kind)) + ?? "unknown" + command = try? container.decodeStringIfPresent(forKey: .command) + name = try? container.decodeStringIfPresent(forKey: .name) + path = try? container.decodeStringIfPresent(forKey: .path) + query = try? container.decodeStringIfPresent(forKey: .query) + } +} + +private struct RawFileUpdateChange: Decodable { + var path: String + var kind: CodexFileUpdateChange.Kind + var diff: String + + private enum CodingKeys: String, CodingKey { + case path + case kind + case diff + } + + private enum KindCodingKeys: String, CodingKey { + case type + case movePath = "move_path" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decode(String.self, forKey: .path) + diff = try container.decode(String.self, forKey: .diff) + + let kindContainer = try container.nestedContainer( + keyedBy: KindCodingKeys.self, + forKey: .kind + ) + switch try kindContainer.decode(String.self, forKey: .type) { + case "add": + kind = .add + case "delete": + kind = .delete + case "update": + kind = .update( + movePath: try kindContainer.decodeIfPresent(String.self, forKey: .movePath) + ) + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: kindContainer, + debugDescription: "Unsupported file update change kind: \(type)" + ) + } + } + + var domainValue: CodexFileUpdateChange { + .init(path: path, kind: kind, diff: diff) + } +} + +struct RawThreadItem: Decodable { + var id: String? + var type: String? + var kind: String? + var text: String? + var review: String? + var phase: String? + var command: String? + var cwd: String? + var processID: String? + var source: String? + var aggregatedOutput: String? + var output: String? + var exitCode: Int? + var durationMs: Int? + var commandActions: [RawCommandAction] + var status: String? + var path: String? + var namespace: String? + var server: String? + var tool: String? + var name: String? + var query: String? + var prompt: String? + var summary: [String]? + var content: [String]? + var arguments: AppServerJSONValue? + var input: AppServerJSONValue? + var result: AppServerJSONValue? + var error: AppServerJSONValue? + private var changes: [RawFileUpdateChange]? + var rawValue: AppServerJSONValue? + + enum CodingKeys: String, CodingKey { + case id + case type + case kind + case text + case review + case phase + case command + case cwd + case processID = "processId" + case source + case aggregatedOutput + case output + case exitCode + case durationMs + case commandActions + case status + case path + case namespace + case server + case tool + case name + case query + case prompt + case summary + case content + case arguments + case input + case result + case error + case changes + } + + init(from decoder: Decoder) throws { + rawValue = try? AppServerJSONValue(from: decoder) + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeStringIfPresent(forKey: .id) + type = try container.decodeStringIfPresent(forKey: .type) + kind = try container.decodeStringIfPresent(forKey: .kind) + text = try container.decodeStringIfPresent(forKey: .text) + review = try container.decodeStringIfPresent(forKey: .review) + phase = try container.decodeStringIfPresent(forKey: .phase) + command = try container.decodeStringIfPresent(forKey: .command) + cwd = try container.decodeStringIfPresent(forKey: .cwd) + processID = try container.decodeStringIfPresent(forKey: .processID) + source = try container.decodeStringIfPresent(forKey: .source) + aggregatedOutput = try container.decodeStringIfPresent(forKey: .aggregatedOutput) + output = try container.decodeStringIfPresent(forKey: .output) + exitCode = try? container.decodeIfPresent(Int.self, forKey: .exitCode) + durationMs = try? container.decodeIfPresent(Int.self, forKey: .durationMs) + commandActions = (try? container.decodeIfPresent([RawCommandAction].self, forKey: .commandActions)) ?? [] + status = try container.decodeStringIfPresent(forKey: .status) + path = try container.decodeStringIfPresent(forKey: .path) + namespace = try container.decodeStringIfPresent(forKey: .namespace) + server = try container.decodeStringIfPresent(forKey: .server) + tool = try container.decodeStringIfPresent(forKey: .tool) + name = try container.decodeStringIfPresent(forKey: .name) + query = try container.decodeStringIfPresent(forKey: .query) + prompt = try container.decodeStringIfPresent(forKey: .prompt) + summary = try container.decodeTextListIfPresent(forKey: .summary) + content = try container.decodeTextListIfPresent(forKey: .content) + arguments = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .arguments) + input = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .input) + result = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .result) + error = try? container.decodeIfPresent(AppServerJSONValue.self, forKey: .error) + changes = try container.decodeIfPresent([RawFileUpdateChange].self, forKey: .changes) + } + + var threadItem: CodexThreadItem? { + makeThreadItem(startedAt: nil, completedAt: nil) + } + + func makeThreadItem( + startedAt: Date?, + completedAt: Date?, + allowsFallbackID: Bool = false + ) -> CodexThreadItem? { + let rawType = type ?? kind ?? "unknown" + let kind = CodexThreadItem.Kind(rawValue: rawType) + guard let itemID = id ?? fallbackItemID(rawType: rawType, allowed: allowsFallbackID) else { + return nil + } + guard let content = content( + kind: kind, + id: itemID, + rawType: rawType, + startedAt: startedAt, + completedAt: completedAt + ) else { + return nil + } + return .init( + id: itemID, + kind: kind, + content: content, + rawPayload: rawPayload + ) + } + + private func fallbackItemID(rawType: String, allowed: Bool) -> String? { + guard allowed else { + return nil + } + return "missing-id:\(rawType):\(UUID().uuidString)" + } + + private func content( + kind: CodexThreadItem.Kind, + id: String, + rawType: String, + startedAt: Date?, + completedAt: Date? + ) -> CodexThreadItem.Content? { + switch kind { + case .userMessage: + return .message(.init(id: id, role: .user, text: messageText)) + case .agentMessage: + return .message( + .init( + id: id, + role: .assistant, + phase: phase.map(CodexMessagePhase.init(rawValue:)), + text: messageText + )) + case .enteredReviewMode, .exitedReviewMode: + return .log(messageText) + case .plan: + return .plan(messageText) + case .reasoning: + let summary = summary ?? [] + let content = content ?? [] + if summary.isEmpty && content.isEmpty { + return .reasoning(.init(summary: messageText)) + } + return .reasoning(.init(summary: summary, content: content)) + case .commandExecution: + return .command( + .init( + command: command ?? "", + cwd: cwd, + output: aggregatedOutput ?? output ?? text, + exitCode: exitCode, + status: status.map(CodexTurnStatus.init(rawValue:)), + startedAt: startedAt, + completedAt: completedAt, + duration: durationMs.map { .milliseconds(Int64($0)) }, + processID: processID, + source: source.map(CodexCommand.Source.init(rawValue:)), + commandActions: commandActions.map(\.codexCommandAction) + )) + case .fileChange: + guard let changes = changes?.map(\.domainValue) else { + return nil + } + return .fileChange( + .init( + path: changes.first?.path, + output: changes.isEmpty ? nil : changes.map(\.diff).joined(separator: "\n"), + status: status.map(CodexTurnStatus.init(rawValue:)) + )) + case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .subAgentActivity, + .webSearch, .imageView, .sleep, .imageGeneration: + return .toolCall( + .init( + namespace: namespace, + server: server, + name: tool ?? name ?? query ?? path, + arguments: arguments?.displayText ?? input?.displayText, + result: result?.displayText ?? text, + error: error?.displayText, + status: status.map(CodexTurnStatus.init(rawValue:)) + )) + case .contextCompaction: + return .contextCompaction(status ?? text) + case .diagnostic, .error: + return .diagnostic(messageText) + case .unknown: + return .unknown(.init(rawType: rawType, text: messageText, payload: rawPayload)) + } + } + + private var messageText: String { + text ?? review ?? content?.joined(separator: "\n") ?? "" + } + + private var rawPayload: Data? { + rawValue.flatMap { try? JSONEncoder().encode($0) } + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeTextListIfPresent(forKey key: Key) throws -> [String]? { + if let values = try? decodeIfPresent([String].self, forKey: key) { + return values.nonEmpty + } + if let value = try? decodeStringIfPresent(forKey: key) { + return [value] + } + if let fragments = try? decodeIfPresent([AppServerTextFragment].self, forKey: key) { + return fragments.compactMap(\.text).nonEmpty + } + return nil + } + + fileprivate func decodeStringIfPresent(forKey key: Key) throws -> String? { + if let string = try? decode(String.self, forKey: key) { + return string + } + if let int = try? decode(Int.self, forKey: key) { + return String(int) + } + if let double = try? decode(Double.self, forKey: key) { + return String(double) + } + if let bool = try? decode(Bool.self, forKey: key) { + return bool ? "true" : "false" + } + return nil + } +} + +private struct AppServerTextFragment: Decodable { + var text: String? + + enum CodingKeys: String, CodingKey { + case text + } + + init(from decoder: Decoder) throws { + let singleValue = try decoder.singleValueContainer() + if singleValue.decodeNil() { + text = nil + return + } + if let text = try? singleValue.decode(String.self) { + self.text = text + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + text = try container.decodeStringIfPresent(forKey: .text) + } +} + +private extension Array where Element == String { + var nonEmpty: [String]? { + isEmpty ? nil : self + } +} + +extension AppServerJSONValue { + var displayText: String? { + switch self { + case .string(let value): + value + case .int(let value): + String(value) + case .double(let value): + String(value) + case .bool(let value): + value ? "true" : "false" + case .object(let value): + value["displayText"]?.displayText + ?? value["text"]?.displayText + ?? value["message"]?.displayText + ?? canonicalJSONString + case .array: + canonicalJSONString + case .null: + nil + } + } + + private var canonicalJSONString: String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(self)).flatMap { String(data: $0, encoding: .utf8) } + } +} diff --git a/Sources/CodexAppServerKit/CodexAppServerRequest.swift b/Sources/CodexAppServerKit/CodexAppServerRequest.swift new file mode 100644 index 00000000..16b5a3f3 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServerRequest.swift @@ -0,0 +1,657 @@ +import CoreFoundation +import Foundation + +package enum CodexServerRequestID: Hashable, Sendable, Codable { + case integer(Int64) + case string(String) + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int64.self) { + self = .integer(value) + } else { + self = .string(try container.decode(String.self)) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .integer(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + } + } + + package init?(jsonObject: Any?) { + switch jsonObject { + case let value as String: + self = .string(value) + case let value as NSNumber where CFGetTypeID(value) != CFBooleanGetTypeID(): + let integer = value.int64Value + guard value.doubleValue.isFinite, + value.doubleValue == Double(integer) else { + return nil + } + self = .integer(integer) + default: + return nil + } + } + + package var jsonObject: Any { + switch self { + case .integer(let value): + value + case .string(let value): + value + } + } +} + +/// A typed request initiated by the Codex app-server. +public enum CodexAppServerRequest: Equatable, Sendable { + case commandExecutionApproval(CodexCommandExecutionApprovalRequest) + case fileChangeApproval(CodexFileChangeApprovalRequest) + case userInput(CodexUserInputRequest) + case mcpElicitation(CodexMCPElicitationRequest) + case permissions(CodexPermissionsRequest) + case dynamicToolCall(CodexDynamicToolCallRequest) + case chatGPTAuthTokensRefresh(CodexChatGPTAuthTokensRefreshRequest) + case attestationGenerate(CodexAttestationGenerateRequest) + case currentTimeRead(CodexCurrentTimeReadRequest) + case unknown(CodexRawServerRequest) + + /// The current-v2 JSON-RPC method represented by this request. + public var method: String { + switch self { + case .commandExecutionApproval: + "item/commandExecution/requestApproval" + case .fileChangeApproval: + "item/fileChange/requestApproval" + case .userInput: + "item/tool/requestUserInput" + case .mcpElicitation: + "mcpServer/elicitation/request" + case .permissions: + "item/permissions/requestApproval" + case .dynamicToolCall: + "item/tool/call" + case .chatGPTAuthTokensRefresh: + "account/chatgptAuthTokens/refresh" + case .attestationGenerate: + "attestation/generate" + case .currentTimeRead: + "currentTime/read" + case .unknown(let request): + request.method + } + } +} + +public struct CodexCommandExecutionApprovalRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String + public let itemID: String + public let startedAtMs: Int64 + public let approvalID: String? + public let environmentID: String? + public let reason: String? + public let networkApprovalContext: CodexJSONValue? + public let command: String? + public let cwd: String? + public let commandActions: [CodexJSONValue]? + public let additionalPermissions: CodexJSONValue? + public let proposedExecpolicyAmendment: CodexExecPolicyAmendment? + public let proposedNetworkPolicyAmendments: [CodexNetworkPolicyAmendment]? + public let availableDecisions: [CodexApprovalDecision]? + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case startedAtMs + case approvalID = "approvalId" + case environmentID = "environmentId" + case reason + case networkApprovalContext + case command + case cwd + case commandActions + case additionalPermissions + case proposedExecpolicyAmendment + case proposedNetworkPolicyAmendments + case availableDecisions + } +} + +public struct CodexFileChangeApprovalRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String + public let itemID: String + public let startedAtMs: Int64 + public let reason: String? + public let grantRoot: String? + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case startedAtMs + case reason + case grantRoot + } +} + +public struct CodexUserInputOption: Codable, Equatable, Sendable { + public let label: String + public let description: String +} + +public struct CodexUserInputQuestion: Codable, Equatable, Sendable { + public let id: String + public let header: String + public let question: String + public let isOther: Bool + public let isSecret: Bool + public let options: [CodexUserInputOption]? +} + +public struct CodexUserInputRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String + public let itemID: String + public let questions: [CodexUserInputQuestion] + public let autoResolutionMs: UInt64? + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case questions + case autoResolutionMs + } +} + +public struct CodexMCPElicitationRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String? + public let serverName: String + public let elicitation: CodexMCPElicitation + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case serverName + case mode + case meta = "_meta" + case message + case requestedSchema + case url + case elicitationID = "elicitationId" + } + + private enum Mode: String, Codable { + case form + case openAIForm = "openai/form" + case url + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.threadID = try container.decode(String.self, forKey: .threadID) + self.turnID = try container.decodeIfPresent(String.self, forKey: .turnID) + self.serverName = try container.decode(String.self, forKey: .serverName) + let meta = try container.decodeIfPresent(CodexJSONValue.self, forKey: .meta) + let message = try container.decode(String.self, forKey: .message) + switch try container.decode(Mode.self, forKey: .mode) { + case .form: + self.elicitation = .form( + meta: meta, + message: message, + requestedSchema: try container.decode( + CodexJSONValue.self, + forKey: .requestedSchema + ) + ) + case .openAIForm: + self.elicitation = .openAIForm( + meta: meta, + message: message, + requestedSchema: try container.decode( + CodexJSONValue.self, + forKey: .requestedSchema + ) + ) + case .url: + self.elicitation = .url( + meta: meta, + message: message, + url: try container.decode(String.self, forKey: .url), + elicitationID: try container.decode(String.self, forKey: .elicitationID) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(threadID, forKey: .threadID) + try container.encode(turnID, forKey: .turnID) + try container.encode(serverName, forKey: .serverName) + switch elicitation { + case .form(let meta, let message, let requestedSchema): + try container.encode(Mode.form, forKey: .mode) + try container.encode(meta, forKey: .meta) + try container.encode(message, forKey: .message) + try container.encode(requestedSchema, forKey: .requestedSchema) + case .openAIForm(let meta, let message, let requestedSchema): + try container.encode(Mode.openAIForm, forKey: .mode) + try container.encode(meta, forKey: .meta) + try container.encode(message, forKey: .message) + try container.encode(requestedSchema, forKey: .requestedSchema) + case .url(let meta, let message, let url, let elicitationID): + try container.encode(Mode.url, forKey: .mode) + try container.encode(meta, forKey: .meta) + try container.encode(message, forKey: .message) + try container.encode(url, forKey: .url) + try container.encode(elicitationID, forKey: .elicitationID) + } + } +} + +public enum CodexMCPElicitation: Equatable, Sendable { + case form(meta: CodexJSONValue?, message: String, requestedSchema: CodexJSONValue) + case openAIForm(meta: CodexJSONValue?, message: String, requestedSchema: CodexJSONValue) + case url(meta: CodexJSONValue?, message: String, url: String, elicitationID: String) +} + +public struct CodexPermissionsRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String + public let itemID: String + public let environmentID: String? + public let startedAtMs: Int64 + public let cwd: String + public let reason: String? + public let permissions: CodexJSONValue + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case environmentID = "environmentId" + case startedAtMs + case cwd + case reason + case permissions + } +} + +public struct CodexDynamicToolCallRequest: Codable, Equatable, Sendable { + public let threadID: String + public let turnID: String + public let callID: String + public let namespace: String? + public let tool: String + public let arguments: CodexJSONValue + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case callID = "callId" + case namespace + case tool + case arguments + } +} + +public enum CodexChatGPTAuthTokensRefreshReason: String, Codable, Equatable, Sendable { + case unauthorized +} + +public struct CodexChatGPTAuthTokensRefreshRequest: Codable, Equatable, Sendable { + public let reason: CodexChatGPTAuthTokensRefreshReason + public let previousAccountID: String? + + private enum CodingKeys: String, CodingKey { + case reason + case previousAccountID = "previousAccountId" + } +} + +public struct CodexAttestationGenerateRequest: Codable, Equatable, Sendable {} + +public struct CodexCurrentTimeReadRequest: Codable, Equatable, Sendable { + public let threadID: String + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +public struct CodexRawServerRequest: Equatable, Sendable { + public let method: String + public let params: Data +} + +/// The host's typed resolution for an app-server request. +public enum CodexAppServerRequestResolution: Equatable, Sendable { + case approval(CodexApprovalDecision) + case userInput(CodexUserInputResponse) + case permissions(CodexPermissionsResponse) + case dynamicToolCall(CodexDynamicToolCallResponse) + case mcpElicitation(CodexMCPElicitationResponse) + case chatGPTAuthTokensRefresh(CodexChatGPTAuthTokensRefreshResponse) + case attestationGenerate(CodexAttestationGenerateResponse) + case currentTimeRead(CodexCurrentTimeReadResponse) + case rejectUnknown(code: Int, message: String) +} + +/// Handles app-server requests that require a host-side response. +public typealias CodexAppServerRequestHandler = + @Sendable (CodexAppServerRequest) async throws -> CodexAppServerRequestResolution + +public enum CodexApprovalDecision: Codable, Equatable, Sendable { + case accept + case acceptForSession + case acceptWithExecpolicyAmendment(CodexExecPolicyAmendment) + case applyNetworkPolicyAmendment(CodexNetworkPolicyAmendment) + case decline + case cancel + + private enum Scalar: String, Codable { + case accept + case acceptForSession + case decline + case cancel + } + + private enum CodingKeys: String, CodingKey { + case acceptWithExecpolicyAmendment + case applyNetworkPolicyAmendment + } + + private enum ExecPolicyCodingKeys: String, CodingKey { + case amendment = "execpolicy_amendment" + } + + private enum NetworkPolicyCodingKeys: String, CodingKey { + case amendment = "network_policy_amendment" + } + + public init(from decoder: Decoder) throws { + if let scalar = try? Scalar(from: decoder) { + self = switch scalar { + case .accept: .accept + case .acceptForSession: .acceptForSession + case .decline: .decline + case .cancel: .cancel + } + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.acceptWithExecpolicyAmendment) { + let nested = try container.nestedContainer( + keyedBy: ExecPolicyCodingKeys.self, + forKey: .acceptWithExecpolicyAmendment + ) + self = .acceptWithExecpolicyAmendment( + try nested.decode(CodexExecPolicyAmendment.self, forKey: .amendment) + ) + } else { + let nested = try container.nestedContainer( + keyedBy: NetworkPolicyCodingKeys.self, + forKey: .applyNetworkPolicyAmendment + ) + self = .applyNetworkPolicyAmendment( + try nested.decode(CodexNetworkPolicyAmendment.self, forKey: .amendment) + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .accept: + try Scalar.accept.encode(to: encoder) + case .acceptForSession: + try Scalar.acceptForSession.encode(to: encoder) + case .decline: + try Scalar.decline.encode(to: encoder) + case .cancel: + try Scalar.cancel.encode(to: encoder) + case .acceptWithExecpolicyAmendment(let amendment): + var container = encoder.container(keyedBy: CodingKeys.self) + var nested = container.nestedContainer( + keyedBy: ExecPolicyCodingKeys.self, + forKey: .acceptWithExecpolicyAmendment + ) + try nested.encode(amendment, forKey: .amendment) + case .applyNetworkPolicyAmendment(let amendment): + var container = encoder.container(keyedBy: CodingKeys.self) + var nested = container.nestedContainer( + keyedBy: NetworkPolicyCodingKeys.self, + forKey: .applyNetworkPolicyAmendment + ) + try nested.encode(amendment, forKey: .amendment) + } + } + + package var isValidForFileChange: Bool { + switch self { + case .accept, .acceptForSession, .decline, .cancel: + true + case .acceptWithExecpolicyAmendment, .applyNetworkPolicyAmendment: + false + } + } +} + +public struct CodexExecPolicyAmendment: Codable, Equatable, Sendable { + public var command: [String] + + public init(command: [String]) { + self.command = command + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.command = try container.decode([String].self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(command) + } +} + +public enum CodexNetworkPolicyRuleAction: String, Codable, Equatable, Sendable { + case allow + case deny +} + +public struct CodexNetworkPolicyAmendment: Codable, Equatable, Sendable { + public var host: String + public var action: CodexNetworkPolicyRuleAction + + public init(host: String, action: CodexNetworkPolicyRuleAction) { + self.host = host + self.action = action + } +} + +public struct CodexUserInputAnswer: Codable, Equatable, Sendable { + public var answers: [String] + + public init(answers: [String]) { + self.answers = answers + } +} + +public struct CodexUserInputResponse: Codable, Equatable, Sendable { + public var answers: [String: CodexUserInputAnswer] + + public init(answers: [String: CodexUserInputAnswer]) { + self.answers = answers + } +} + +public struct CodexGrantedPermissionProfile: Codable, Equatable, Sendable { + public var network: CodexJSONValue? + public var fileSystem: CodexJSONValue? + + public init(network: CodexJSONValue?, fileSystem: CodexJSONValue?) { + self.network = network + self.fileSystem = fileSystem + } +} + +public enum CodexPermissionGrantScope: String, Codable, Equatable, Sendable { + case turn + case session +} + +public struct CodexPermissionsResponse: Codable, Equatable, Sendable { + public var permissions: CodexGrantedPermissionProfile + public var scope: CodexPermissionGrantScope + public var strictAutoReview: Bool? + + public init( + permissions: CodexGrantedPermissionProfile, + scope: CodexPermissionGrantScope, + strictAutoReview: Bool? = nil + ) { + self.permissions = permissions + self.scope = scope + self.strictAutoReview = strictAutoReview + } +} + +public enum CodexDynamicToolCallOutputContentItem: Codable, Equatable, Sendable { + case inputText(text: String) + case inputImage(imageURL: String) + + private enum CodingKeys: String, CodingKey { + case type + case text + case imageURL = "imageUrl" + } + + private enum Kind: String, Codable { + case inputText + case inputImage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .type) { + case .inputText: + self = .inputText(text: try container.decode(String.self, forKey: .text)) + case .inputImage: + self = .inputImage(imageURL: try container.decode(String.self, forKey: .imageURL)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .inputText(let text): + try container.encode(Kind.inputText, forKey: .type) + try container.encode(text, forKey: .text) + case .inputImage(let imageURL): + try container.encode(Kind.inputImage, forKey: .type) + try container.encode(imageURL, forKey: .imageURL) + } + } +} + +public struct CodexDynamicToolCallResponse: Codable, Equatable, Sendable { + public var contentItems: [CodexDynamicToolCallOutputContentItem] + public var success: Bool + + public init(contentItems: [CodexDynamicToolCallOutputContentItem], success: Bool) { + self.contentItems = contentItems + self.success = success + } +} + +public enum CodexMCPElicitationAction: String, Codable, Equatable, Sendable { + case accept + case decline + case cancel +} + +public struct CodexMCPElicitationResponse: Codable, Equatable, Sendable { + public var action: CodexMCPElicitationAction + public var content: CodexJSONValue? + public var meta: CodexJSONValue? + + public init( + action: CodexMCPElicitationAction, + content: CodexJSONValue? = nil, + meta: CodexJSONValue? = nil + ) { + self.action = action + self.content = content + self.meta = meta + } + + private enum CodingKeys: String, CodingKey { + case action + case content + case meta = "_meta" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(action, forKey: .action) + try container.encode(content, forKey: .content) + try container.encode(meta, forKey: .meta) + } +} + +public struct CodexChatGPTAuthTokensRefreshResponse: Codable, Equatable, Sendable { + public var accessToken: String + public var chatGPTAccountID: String + public var chatGPTPlanType: String? + + public init( + accessToken: String, + chatGPTAccountID: String, + chatGPTPlanType: String? = nil + ) { + self.accessToken = accessToken + self.chatGPTAccountID = chatGPTAccountID + self.chatGPTPlanType = chatGPTPlanType + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case chatGPTAccountID = "chatgptAccountId" + case chatGPTPlanType = "chatgptPlanType" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(accessToken, forKey: .accessToken) + try container.encode(chatGPTAccountID, forKey: .chatGPTAccountID) + try container.encode(chatGPTPlanType, forKey: .chatGPTPlanType) + } +} + +public struct CodexAttestationGenerateResponse: Codable, Equatable, Sendable { + public var token: String + + public init(token: String) { + self.token = token + } +} + +public struct CodexCurrentTimeReadResponse: Codable, Equatable, Sendable { + public var currentTimeAt: Int64 + + public init(currentTimeAt: Int64) { + self.currentTimeAt = currentTimeAt + } +} diff --git a/Sources/CodexAppServerKit/CodexAppServerRequestCodec.swift b/Sources/CodexAppServerKit/CodexAppServerRequestCodec.swift new file mode 100644 index 00000000..a32c1efe --- /dev/null +++ b/Sources/CodexAppServerKit/CodexAppServerRequestCodec.swift @@ -0,0 +1,143 @@ +import Foundation + +package enum CodexServerRequestResponse: Equatable, Sendable { + case result(Data) + case error(code: Int, message: String) +} + +package struct CodexAppServerRequestCodec: Sendable { + package init() {} + + package func decode(method: String, params: Data) throws -> CodexAppServerRequest { + let decoder = JSONDecoder() + return switch method { + case "item/commandExecution/requestApproval": + .commandExecutionApproval( + try decoder.decode(CodexCommandExecutionApprovalRequest.self, from: params) + ) + case "item/fileChange/requestApproval": + .fileChangeApproval( + try decoder.decode(CodexFileChangeApprovalRequest.self, from: params) + ) + case "item/tool/requestUserInput": + .userInput(try decoder.decode(CodexUserInputRequest.self, from: params)) + case "mcpServer/elicitation/request": + .mcpElicitation(try decoder.decode(CodexMCPElicitationRequest.self, from: params)) + case "item/permissions/requestApproval": + .permissions(try decoder.decode(CodexPermissionsRequest.self, from: params)) + case "item/tool/call": + .dynamicToolCall(try decoder.decode(CodexDynamicToolCallRequest.self, from: params)) + case "account/chatgptAuthTokens/refresh": + .chatGPTAuthTokensRefresh( + try decoder.decode(CodexChatGPTAuthTokensRefreshRequest.self, from: params) + ) + case "attestation/generate": + .attestationGenerate( + try decoder.decode(CodexAttestationGenerateRequest.self, from: params) + ) + case "currentTime/read": + .currentTimeRead(try decoder.decode(CodexCurrentTimeReadRequest.self, from: params)) + default: + .unknown(.init(method: method, params: params)) + } + } + + package func response( + to request: CodexAppServerRequest, + resolution: CodexAppServerRequestResolution + ) -> CodexServerRequestResponse { + do { + let encoder = JSONEncoder() + switch (request, resolution) { + case (.commandExecutionApproval, .approval(let decision)): + return .result(try encoder.encode(ApprovalResponse(decision: decision))) + case (.fileChangeApproval, .approval(let decision)) + where decision.isValidForFileChange: + return .result(try encoder.encode(ApprovalResponse(decision: decision))) + case (.userInput, .userInput(let response)): + return .result(try encoder.encode(response)) + case (.mcpElicitation, .mcpElicitation(let response)): + return .result(try encoder.encode(response)) + case (.permissions, .permissions(let response)): + return .result(try encoder.encode(response)) + case (.dynamicToolCall, .dynamicToolCall(let response)): + return .result(try encoder.encode(response)) + case (.chatGPTAuthTokensRefresh, .chatGPTAuthTokensRefresh(let response)): + return .result(try encoder.encode(response)) + case (.attestationGenerate, .attestationGenerate(let response)): + return .result(try encoder.encode(response)) + case (.currentTimeRead, .currentTimeRead(let response)): + return .result(try encoder.encode(response)) + case (_, .rejectUnknown(let code, let message)): + return .error(code: code, message: message) + default: + return Self.internalError( + "Server request resolution does not match \(request.method)." + ) + } + } catch { + return Self.internalError( + "Failed to encode response for \(request.method): \(error.localizedDescription)" + ) + } + } + + package func handle( + _ request: CodexAppServerRequest, + using handler: CodexAppServerRequestHandler + ) async -> CodexServerRequestResponse { + do { + return response(to: request, resolution: try await handler(request)) + } catch { + return Self.internalError( + "Handler failed for \(request.method): \(error.localizedDescription)" + ) + } + } + + package static func builtInResolution( + for request: CodexAppServerRequest, + clock: CodexAppServerClock + ) -> CodexAppServerRequestResolution { + switch request { + case .commandExecutionApproval, .fileChangeApproval: + .approval(.decline) + case .userInput: + .userInput(.init(answers: [:])) + case .mcpElicitation: + .mcpElicitation(.init(action: .cancel, content: nil, meta: nil)) + case .permissions: + .permissions(.init( + permissions: .init(network: nil, fileSystem: nil), + scope: .turn, + strictAutoReview: false + )) + case .dynamicToolCall: + .dynamicToolCall(.init( + contentItems: [ + .inputText(text: "Dynamic tool calls are not supported by this client."), + ], + success: false + )) + case .chatGPTAuthTokensRefresh, .attestationGenerate: + .rejectUnknown( + code: -32601, + message: "No client provider is configured for \(request.method)." + ) + case .currentTimeRead: + .currentTimeRead(.init( + currentTimeAt: Int64(clock.now().timeIntervalSince1970.rounded(.down)) + )) + case .unknown: + .rejectUnknown(code: -32601, message: "Method not found: \(request.method)") + } + } + + package static func internalError(_ message: String) -> CodexServerRequestResponse { + .error(code: -32603, message: message) + } + + private struct ApprovalResponse: Encodable { + var decision: CodexApprovalDecision + } +} diff --git a/Sources/CodexAppServerKit/CodexDomainTypes.swift b/Sources/CodexAppServerKit/CodexDomainTypes.swift new file mode 100644 index 00000000..d6fd834a --- /dev/null +++ b/Sources/CodexAppServerKit/CodexDomainTypes.swift @@ -0,0 +1,3468 @@ +import Foundation + +public struct CodexPrompt: ExpressibleByStringLiteral, Equatable, Sendable { + public var parts: [Part] + + public init(parts: [Part]) { + self.parts = parts + } + + public init(@CodexPromptBuilder _ content: () throws -> CodexPrompt) rethrows { + self = try content() + } + + public init(stringLiteral value: String) { + self.parts = [.text(value)] + } + + public init(_ text: String) { + self.parts = [.text(text)] + } + + public enum Part: Equatable, Sendable { + case text(String) + case imageURL(URL) + case localImage(URL) + case skill(name: String, path: URL) + case mention(name: String, path: URL) + } +} + +@resultBuilder +public enum CodexPromptBuilder { + public static func buildBlock(_ components: CodexPrompt...) -> CodexPrompt { + .init(parts: components.flatMap(\.parts)) + } + + public static func buildExpression(_ expression: CodexPrompt) -> CodexPrompt { + expression + } + + public static func buildExpression(_ expression: CodexPrompt.Part) -> CodexPrompt { + .init(parts: [expression]) + } + + public static func buildExpression(_ expression: String) -> CodexPrompt { + .init(expression) + } + + public static func buildOptional(_ component: CodexPrompt?) -> CodexPrompt { + component ?? .init(parts: []) + } + + public static func buildEither(first component: CodexPrompt) -> CodexPrompt { + component + } + + public static func buildEither(second component: CodexPrompt) -> CodexPrompt { + component + } + + public static func buildArray(_ components: [CodexPrompt]) -> CodexPrompt { + .init(parts: components.flatMap(\.parts)) + } + + public static func buildLimitedAvailability(_ component: CodexPrompt) -> CodexPrompt { + component + } +} + +public struct CodexInstructions: Equatable, Sendable { + public var base: String? + public var developer: String? + + public init(base: String? = nil, developer: String? = nil) { + self.base = base + self.developer = developer + } + + public init(_ developer: String) { + self.init(developer: developer) + } + + public init(@CodexInstructionsBuilder _ developer: () throws -> String) rethrows { + self.init(developer: try developer()) + } + + public static func base(_ text: String) -> Self { + .init(base: text) + } + + public static func developer(_ text: String) -> Self { + .init(developer: text) + } +} + +/// A JSON value used for app-server configuration and structured output schema. +public enum CodexJSONValue: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([CodexJSONValue]) + case object([String: CodexJSONValue]) + case null + + public init(stringLiteral value: String) { + self = .string(value) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([CodexJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: CodexJSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +extension CodexJSONValue { + package var appServerJSONValue: AppServerJSONValue { + switch self { + case .string(let value): + .string(value) + case .int(let value): + .int(value) + case .double(let value): + .double(value) + case .bool(let value): + .bool(value) + case .array(let value): + .array(value.map(\.appServerJSONValue)) + case .object(let value): + .object(value.mapValues(\.appServerJSONValue)) + case .null: + .null + } + } +} + +@resultBuilder +public enum CodexInstructionsBuilder { + public static func buildBlock(_ components: String...) -> String { + components.filter { $0.isEmpty == false }.joined(separator: "\n") + } + + public static func buildExpression(_ expression: String) -> String { + expression + } + + public static func buildOptional(_ component: String?) -> String { + component ?? "" + } + + public static func buildEither(first component: String) -> String { + component + } + + public static func buildEither(second component: String) -> String { + component + } + + public static func buildArray(_ components: [String]) -> String { + components.filter { $0.isEmpty == false }.joined(separator: "\n") + } + + public static func buildLimitedAvailability(_ component: String) -> String { + component + } +} + +public enum CodexSandbox: String, Codable, Equatable, Sendable { + case readOnly + case workspaceWrite + case fullAccess + + package var threadSandboxValue: String { + switch self { + case .readOnly: + "read-only" + case .workspaceWrite: + "workspace-write" + case .fullAccess: + "danger-full-access" + } + } + + package var turnSandboxPolicy: AppServerAPI.Turn.SandboxPolicy { + switch self { + case .readOnly: + .readOnly(networkAccess: false) + case .workspaceWrite: + .workspaceWrite( + writableRoots: [], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ) + case .fullAccess: + .dangerFullAccess + } + } +} + +/// Permission profile selection for a newly created Codex thread. +public struct CodexThreadPermissions: Equatable, Sendable { + package enum Kind: Equatable, Sendable { + case profileID(String) + case profileSelection(String) + } + + package var kind: Kind + + package init(kind: Kind) { + self.kind = kind + } + + public static func profile(id: String) -> Self { + .init(kind: .profileID(id)) + } + + package static func profileSelection(id: String) -> Self { + .init(kind: .profileSelection(id)) + } + + package var appServerPermissions: AppServerAPI.Thread.Start.Permissions { + switch kind { + case .profileID(let id): + .profileID(id) + case .profileSelection(let id): + .profileSelection(.init(id: id)) + } + } +} + +public enum CodexApprovalMode: String, Codable, Equatable, Sendable { + case autoReview + case denyAll + + package var approvalPolicy: String { + switch self { + case .autoReview: + "on-request" + case .denyAll: + "never" + } + } + + package var approvalsReviewer: String? { + switch self { + case .autoReview: + "auto_review" + case .denyAll: + nil + } + } +} + +/// The amount of model reasoning requested for a Codex turn. +public struct CodexReasoningEffort: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let minimal = Self(rawValue: "minimal") + public static let low = Self(rawValue: "low") + public static let medium = Self(rawValue: "medium") + public static let high = Self(rawValue: "high") + public static let xhigh = Self(rawValue: "xhigh") +} + +extension CodexReasoningEffort: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The reasoning summary mode requested for a Codex turn. +public struct CodexReasoningSummary: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let auto = Self(rawValue: "auto") + public static let concise = Self(rawValue: "concise") + public static let detailed = Self(rawValue: "detailed") +} + +extension CodexReasoningSummary: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The app-server personality to apply to a thread or turn. +public struct CodexPersonality: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let none = Self(rawValue: "none") + public static let friendly = Self(rawValue: "friendly") + public static let pragmatic = Self(rawValue: "pragmatic") +} + +/// The source that created a new app-server session. +public enum CodexThreadStartSource: String, Codable, Equatable, Sendable { + case startup + case clear + + package var appServerSource: AppServerAPI.Thread.Start.Source { + switch self { + case .startup: + .startup + case .clear: + .clear + } + } +} + +/// Client-supplied analytics classification for a Codex thread. +public struct CodexThreadSource: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let user = Self(rawValue: "user") + public static let subagent = Self(rawValue: "subagent") + public static let memoryConsolidation = Self(rawValue: "memory_consolidation") + + package var appServerSource: AppServerAPI.Thread.Source { + .init(rawValue: rawValue) + } +} + +public struct CodexGenerationOptions: Equatable, Sendable { + public var model: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var cwd: URL? + public var effort: CodexReasoningEffort? + public var serviceTier: String? + public var summary: CodexReasoningSummary? + public var outputSchema: CodexJSONValue? + public var personality: CodexPersonality? + public var clientUserMessageID: String? + + public init( + model: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + cwd: URL? = nil, + effort: CodexReasoningEffort? = nil, + serviceTier: String? = nil, + summary: CodexReasoningSummary? = nil, + outputSchema: CodexJSONValue? = nil, + personality: CodexPersonality? = nil, + clientUserMessageID: String? = nil + ) { + self.model = model + self.approvalMode = approvalMode + self.sandbox = sandbox + self.cwd = cwd + self.effort = effort + self.serviceTier = serviceTier + self.summary = summary + self.outputSchema = outputSchema + self.personality = personality + self.clientUserMessageID = clientUserMessageID + } +} + +public struct CodexThreadID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } +} + +public struct CodexThread: Identifiable, Sendable { + public struct Options: Equatable, Sendable { + public var model: String? + public var modelProvider: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var permissions: CodexThreadPermissions? + public var serviceTier: String? + public var ephemeral: Bool? + public var config: [String: CodexJSONValue]? + public var personality: CodexPersonality? + public var serviceName: String? + public var sessionStartSource: CodexThreadStartSource? + public var threadSource: CodexThreadSource? + + public init( + model: String? = nil, + modelProvider: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + permissions: CodexThreadPermissions? = nil, + serviceTier: String? = nil, + ephemeral: Bool? = nil, + config: [String: CodexJSONValue]? = nil, + personality: CodexPersonality? = nil, + serviceName: String? = nil, + sessionStartSource: CodexThreadStartSource? = nil, + threadSource: CodexThreadSource? = nil + ) { + self.model = model + self.modelProvider = modelProvider + self.approvalMode = approvalMode + self.sandbox = sandbox + self.permissions = permissions + self.serviceTier = serviceTier + self.ephemeral = ephemeral + self.config = config + self.personality = personality + self.serviceName = serviceName + self.sessionStartSource = sessionStartSource + self.threadSource = threadSource + } + } + + public typealias ResumeOptions = Options + + public let id: CodexThreadID + public let workspace: URL? + public let model: String? + + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + package let turnReplayStore: TurnReplayStore + package let connectionLease: AppServerConnectionLease + + package init( + id: CodexThreadID, + workspace: URL? = nil, + model: String? = nil, + client: AppServerClient, + router: CodexAppServerNotificationRouter, + connectionLease: AppServerConnectionLease + ) { + self.id = id + self.workspace = workspace + self.model = model + self.client = client + self.router = router + self.turnReplayStore = router.turnReplayStore + self.connectionLease = connectionLease + } +} + +/// The target that `codex app-server` should review. +public enum CodexReviewTarget: Codable, Hashable, Sendable { + /// Review the current uncommitted working tree changes. + case uncommittedChanges + + /// Review changes relative to a base branch. + case baseBranch(String) + + /// Review a specific commit. + /// + /// `title` is optional metadata that the app-server may use for display. + case commit(sha: String, title: String? = nil) + + /// Review using custom app-server instructions. + case custom(instructions: String) + + private enum CodingKeys: String, CodingKey { + case type + case branch + case sha + case title + case instructions + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "uncommittedChanges": + self = .uncommittedChanges + case "baseBranch": + self = .baseBranch(try container.decode(String.self, forKey: .branch)) + case "commit": + self = .commit( + sha: try container.decode(String.self, forKey: .sha), + title: try container.decodeIfPresent(String.self, forKey: .title) + ) + case "custom": + self = .custom(instructions: try container.decode(String.self, forKey: .instructions)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown review target type: \(type)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .uncommittedChanges: + try container.encode("uncommittedChanges", forKey: .type) + case .baseBranch(let branch): + try container.encode("baseBranch", forKey: .type) + try container.encode(branch, forKey: .branch) + case .commit(let sha, let title): + try container.encode("commit", forKey: .type) + try container.encode(sha, forKey: .sha) + try container.encodeIfPresent(title, forKey: .title) + case .custom(let instructions): + try container.encode("custom", forKey: .type) + try container.encode(instructions, forKey: .instructions) + } + } +} + +/// How `review/start` should deliver review work. +public enum CodexReviewDelivery: String, Codable, Equatable, Sendable { + /// Run the review in the current thread. + case inline + + /// Let the app-server create a detached review thread when supported. + case detached +} + +/// Persistable app-server identity for a Codex review run. +public struct CodexReviewIdentity: Codable, Equatable, Identifiable, Sendable { + /// The source thread where the review was started. + public var threadID: CodexThreadID + + /// The app-server turn producing the review response. + public var turnID: CodexTurnID + + /// The detached review thread, when app-server created one. + public var reviewThreadID: CodexThreadID? + + /// The active review thread model, when known. + public var model: String? + + public var id: CodexTurnID { + turnID + } + + /// Alias for `threadID` that makes review lifecycle ownership explicit. + public var sourceThreadID: CodexThreadID { + threadID + } + + /// The thread that owns the currently active review turn. + public var activeTurnThreadID: CodexThreadID { + reviewThreadID ?? sourceThreadID + } + + /// Source and review thread identities in source-first order. + public var associatedThreadIDs: [CodexThreadID] { + if activeTurnThreadID == sourceThreadID { + return [sourceThreadID] + } + return [sourceThreadID, activeTurnThreadID] + } + + /// Thread identities to clean up after a review, with detached review + /// threads before the source thread. + public var cleanupThreadIDs: [CodexThreadID] { + if activeTurnThreadID == sourceThreadID { + return [sourceThreadID] + } + return [activeTurnThreadID, sourceThreadID] + } + + public init( + threadID: CodexThreadID, + turnID: CodexTurnID, + reviewThreadID: CodexThreadID? = nil, + model: String? = nil + ) { + self.threadID = threadID + self.turnID = turnID + self.reviewThreadID = reviewThreadID + self.model = model + } +} + +/// One thread deletion that failed during review lifecycle cleanup. +public struct CodexReviewCleanupFailure: Equatable, Sendable { + /// Thread whose deletion failed. + public var threadID: CodexThreadID + + /// Stable diagnostic message from the app-server operation. + public var message: String + + public init(threadID: CodexThreadID, message: String) { + self.threadID = threadID + self.message = message + } +} + +/// Ordered result of deleting the threads retained by a review lifecycle. +public struct CodexReviewCleanupResult: Equatable, Sendable { + /// Thread identifiers in the exact source-last order cleanup attempted. + public var attemptedThreadIDs: [CodexThreadID] + + /// Failed deletions in attempted order. + public var failures: [CodexReviewCleanupFailure] + + public var succeeded: Bool { + failures.isEmpty + } + + public init( + attemptedThreadIDs: [CodexThreadID], + failures: [CodexReviewCleanupFailure] + ) { + self.attemptedThreadIDs = attemptedThreadIDs + self.failures = failures + } +} + +/// Transient token for a review restart prepared by ``CodexAppServer``. +public struct CodexReviewRestartToken: Equatable, Identifiable, Sendable { + public typealias ID = String + + /// The app-server-local restart token identity. + public var id: ID + + /// The review identity that was interrupted while preparing the restart. + public var interruptedIdentity: CodexReviewIdentity + + public init(id: ID, interruptedIdentity: CodexReviewIdentity) { + self.id = id + self.interruptedIdentity = interruptedIdentity + } +} + +/// Thread events projected for a `CodexReviewSession`. +package enum CodexReviewEvent: Equatable, Sendable { + case turnStarted(CodexTurnID) + case snapshot(CodexTurnSnapshot) + case terminal(CodexTurnOutcome) + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case message(CodexMessage, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) + case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) + case diagnostic(CodexTurnDiagnostic, turnID: CodexTurnID) + case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) + case statusChanged(CodexThreadStatus) + case closed + case unknown(CodexRawNotification) + + package init(_ event: CodexThreadEvent) { + switch event { + case .turnStarted(let turnID): + self = .turnStarted(turnID) + case .snapshot(let snapshot): + self = .snapshot(snapshot) + case .terminal(let outcome): + self = .terminal(outcome) + case .itemStarted(let item, let turnID): + self = .itemStarted(item, turnID: turnID) + case .itemUpdated(let item, let turnID): + self = .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item, let turnID): + self = .itemCompleted(item, turnID: turnID) + case .message(let message, let turnID): + self = .message(message, turnID: turnID) + case .messageDelta(let delta, let turnID): + self = .messageDelta(delta, turnID: turnID) + case .reasoningSummaryPartAdded(let part, let turnID): + self = .reasoningSummaryPartAdded(part, turnID: turnID) + case .reasoningDelta(let delta, let turnID): + self = .reasoningDelta(delta, turnID: turnID) + case .diagnostic(let diagnostic, let turnID): + self = .diagnostic(diagnostic, turnID: turnID) + case .tokenUsageUpdated(let usage, let turnID): + self = .tokenUsageUpdated(usage, turnID: turnID) + case .statusChanged(let status): + self = .statusChanged(status) + case .closed: + self = .closed + case .unknown(let raw): + self = .unknown(raw) + } + } + + package init(_ event: CodexTurnEvent, turnID: CodexTurnID) { + switch event { + case .started(let eventTurnID): + self = .turnStarted(eventTurnID) + case .snapshot(let snapshot): + self = .snapshot(snapshot) + case .terminal(let outcome): + self = .terminal(outcome) + case .itemStarted(let item): + self = .itemStarted(item, turnID: turnID) + case .itemUpdated(let item): + self = .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item): + self = .itemCompleted(item, turnID: turnID) + case .message(let message): + self = .message(message, turnID: turnID) + case .messageDelta(let delta): + self = .messageDelta(delta, turnID: turnID) + case .reasoningSummaryPartAdded(let part): + self = .reasoningSummaryPartAdded(part, turnID: turnID) + case .reasoningDelta(let delta): + self = .reasoningDelta(delta, turnID: turnID) + case .diagnostic(let diagnostic): + self = .diagnostic(diagnostic, turnID: turnID) + case .tokenUsageUpdated(let usage): + self = .tokenUsageUpdated(usage, turnID: turnID) + case .unknown(let raw): + self = .unknown(raw) + } + } +} + +/// Incremental progress derived from the review turn's thread events. +package enum CodexReviewProgress: Equatable, Sendable { + case running(transcript: CodexTranscript, usage: CodexTokenUsage?) + case terminal(CodexTurnOutcome) +} + +/// A review run started by `codex app-server`. +public struct CodexReviewSession: Identifiable, Sendable { + /// The response turn identifier, used as the stable session identity. + public var id: CodexTurnID { + turnID + } + + /// The thread where `startReview(target:delivery:)` was called. + public let threadID: CodexThreadID + + /// The app-server turn that is producing the review response. + public let turnID: CodexTurnID + + /// The thread that emits review events and logs. + /// + /// This equals `threadID` for inline reviews and may differ for detached + /// reviews. + public let reviewThreadID: CodexThreadID + + /// The active review thread model, when known. + public let model: String? + + /// The initial turn returned synchronously by `review/start`. + /// + /// Inline reviews may not emit a separate `turn/started` notification, and + /// freshly-created rollouts may not be readable yet. UI clients should seed + /// their transcript from this turn before consuming live events. + public let initialTurn: CodexTurnSnapshot + + /// The live response stream for the review turn. + package let response: CodexResponseStream + + package init( + threadID: CodexThreadID, + turnID: CodexTurnID, + reviewThreadID: CodexThreadID, + model: String?, + initialTurn: CodexTurnSnapshot, + response: CodexResponseStream + ) { + self.threadID = threadID + self.turnID = turnID + self.reviewThreadID = reviewThreadID + self.model = model + self.initialTurn = initialTurn + self.response = response + } + + /// Persistable identity for this review run. + public var identity: CodexReviewIdentity { + .init( + threadID: sourceThreadID, + turnID: turnID, + reviewThreadID: reviewThreadID == sourceThreadID ? nil : reviewThreadID, + model: model + ) + } + + /// Alias for `threadID` that makes the source/review split explicit. + public var sourceThreadID: CodexThreadID { + threadID + } + + /// The thread that owns the currently active review turn. + public var activeTurnThreadID: CodexThreadID { + reviewThreadID + } + + /// Source and review thread identities in source-first order. + public var associatedThreadIDs: [CodexThreadID] { + if activeTurnThreadID == sourceThreadID { + return [sourceThreadID] + } + return [sourceThreadID, activeTurnThreadID] + } + + /// Thread identities to clean up after this review, with detached review + /// threads before the source thread. + public var cleanupThreadIDs: [CodexThreadID] { + if activeTurnThreadID == sourceThreadID { + return [sourceThreadID] + } + return [activeTurnThreadID, sourceThreadID] + } + + /// Thread events filtered to the review turn. + package var events: CodexReviewEventSequence { + .init(events: response.turn.events, turnID: turnID) + } + + /// Agent messages emitted by the review thread. + package var messages: CodexTurnMessageSequence { + .init(events: response.turn.events) + } + + /// Incremental transcript snapshots for the review thread. + package var transcriptUpdates: CodexTurnTranscriptSequence { + .init(events: response.turn.events) + } + + /// Log-oriented item events emitted by the review thread. + package var logEntries: CodexTurnLogSequence { + .init(events: response.turn.events, turnID: turnID) + } + + /// Incremental progress snapshots for the review thread. + package var progress: CodexReviewProgressSequence { + .init( + turnID: turnID, + store: response.turn.turnReplayStore, + state: response.turn.state + ) + } + + /// Collects the review response until the turn finishes. + public func collect(timeout: Duration? = nil) async throws -> CodexTurnOutcome { + try await response.collect(timeout: timeout) + } + + /// Returns the terminal outcome already committed for this review generation. + /// + /// This operation never waits for a live turn and never sends a request. A + /// connection termination committed before a turn outcome is surfaced as an + /// error instead of being synthesized into a turn result. + public func terminalOutcomeIfKnown() async throws -> CodexTurnOutcome? { + try await response.turn.state.cachedOutcome() + } + + /// Cancels the running review turn. + /// + /// - Returns: The turn that the app-server actually cancelled. This can + /// differ from `turnID` when the app-server reports a newer active turn. + @discardableResult + public func cancel() async throws -> CodexTurnCancellation { + try await response.cancel() + } + + @discardableResult + package func cancel( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? + ) async throws -> CodexTurnCancellation { + try await response.cancel(willCancelActiveTurn: willCancelActiveTurn) + } + + /// Sends additional input to the running review turn. + package func steer(with prompt: CodexPrompt) async throws { + try await response.steer(with: prompt) + } + + /// Sends additional text input to the running review turn. + package func steer(with prompt: String) async throws { + try await response.steer(with: prompt) + } + + /// Closes the app-server connection shared by this review session. + public func closeConnection() async { + await response.closeConnection() + } +} + +public struct CodexTurnID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } +} + +package struct CodexTurn: Identifiable, Sendable { + package let id: CodexTurnID + package let threadID: CodexThreadID + + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + package let turnReplayStore: TurnReplayStore + package let state: TurnGenerationHandleState + + package init( + id: CodexTurnID, + threadID: CodexThreadID, + client: AppServerClient, + router: CodexAppServerNotificationRouter, + turnReplayStore: TurnReplayStore, + state: TurnGenerationHandleState + ) { + precondition( + router.turnReplayStore === turnReplayStore, + "A turn and its router must share one replay store identity." + ) + self.id = id + self.threadID = threadID + self.client = client + self.router = router + self.turnReplayStore = turnReplayStore + self.state = state + } +} + +/// Git repository metadata captured for a thread by the app-server. +public struct CodexThreadGitInfo: Equatable, Sendable { + /// The captured commit SHA, when the thread store recorded one. + public var sha: String? + /// The captured branch name, when the thread store recorded one. + public var branch: String? + /// The raw origin remote value. Git remotes are not required to use URL syntax. + public var originURL: String? + + public init( + sha: String? = nil, + branch: String? = nil, + originURL: String? = nil + ) { + self.sha = sha + self.branch = branch + self.originURL = originURL + } +} + +/// The exact session origin reported by the app-server for a thread. +/// +/// This differs from ``CodexThreadSource``, which is a client-supplied analytics +/// classification used when starting a thread. +public enum CodexThreadSessionSource: Hashable, Sendable { + public enum SubAgent: Hashable, Sendable { + public struct ThreadSpawn: Hashable, Sendable { + public var parentThreadID: CodexThreadID + public var depth: Int + public var agentPath: String? + public var agentNickname: String? + public var agentRole: String? + + public init( + parentThreadID: CodexThreadID, + depth: Int, + agentPath: String? = nil, + agentNickname: String? = nil, + agentRole: String? = nil + ) { + self.parentThreadID = parentThreadID + self.depth = depth + self.agentPath = agentPath + self.agentNickname = agentNickname + self.agentRole = agentRole + } + } + + case review + case compact + case threadSpawn(ThreadSpawn) + case memoryConsolidation + case other(String) + } + + case cli + case vscode + case exec + case appServer + case custom(String) + case subAgent(SubAgent) + case unknown + + /// A coarse compatibility projection suitable for source-kind filtering. + /// + /// Custom sources return `nil` because the app-server does not include them + /// in any source-kind filter. + public var sourceKind: CodexThreadSourceKind? { + switch self { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .custom: + nil + case .unknown: + .unknown + case .subAgent(.review): + .subAgentReview + case .subAgent(.compact): + .subAgentCompact + case .subAgent(.threadSpawn): + .subAgentThreadSpawn + case .subAgent(.memoryConsolidation): + .subAgent + case .subAgent(.other): + .subAgentOther + } + } + + /// Returns whether this exact source is included by an app-server source-kind filter. + public func matches(sourceKind: CodexThreadSourceKind) -> Bool { + if sourceKind == .subAgent, case .subAgent = self { + return true + } + return self.sourceKind == sourceKind + } +} + +public struct CodexThreadSnapshot: Identifiable, Equatable, Sendable { + package enum Field: String, Hashable, Sendable { + case sessionID + case parentThreadID + case workspace + case name + case preview + case modelProvider + case source + case sourceKind + case gitInfo + case createdAt + case updatedAt + case recencyAt + case status + case ephemeral + case turns + } + + public var id: CodexThreadID + /// The app-server session identifier, when present in the snapshot. + public var sessionID: String? { + didSet { presentFields.insert(.sessionID) } + } + /// The direct parent thread identifier reported by the app-server. + public var parentThreadID: CodexThreadID? { + didSet { presentFields.insert(.parentThreadID) } + } + public var workspace: URL? + public var name: String? + public var preview: String? + public var modelProvider: String? + private var sourceStorage: CodexThreadSessionSource? + private var sourceKindFallback: CodexThreadSourceKind? + /// The exact thread session origin reported by the app-server. + public var source: CodexThreadSessionSource? { + get { sourceStorage } + set { + sourceStorage = newValue + sourceKindFallback = nil + presentFields.remove(.sourceKind) + presentFields.insert(.source) + } + } + /// A coarse source projection retained for source-kind filtering compatibility. + public var sourceKind: CodexThreadSourceKind? { + get { sourceStorage?.sourceKind ?? sourceKindFallback } + set { + if let sourceStorage, newValue == sourceStorage.sourceKind { + presentFields.remove(.sourceKind) + presentFields.insert(.source) + return + } + sourceStorage = nil + sourceKindFallback = newValue + presentFields.remove(.source) + presentFields.insert(.sourceKind) + } + } + /// Git repository metadata captured for this thread by the app-server. + public var gitInfo: CodexThreadGitInfo? { + didSet { presentFields.insert(.gitInfo) } + } + public var createdAt: Date? + public var updatedAt: Date? + public var recencyAt: Date? + public var status: CodexThreadStatus? + public var ephemeral: Bool? + public var turns: [CodexTurnSnapshot]? + /// True only when `turns` may replace cached transcript items. The + /// initializer clamps producer intent to the turns' actual load state, so + /// summary or not-loaded items can never be marked authoritative. + package var turnItemsAreAuthoritative: Bool + package var presentFields: Set + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + && lhs.sessionID == rhs.sessionID + && lhs.parentThreadID == rhs.parentThreadID + && lhs.workspace == rhs.workspace + && lhs.name == rhs.name + && lhs.preview == rhs.preview + && lhs.modelProvider == rhs.modelProvider + && lhs.source == rhs.source + && lhs.sourceKind == rhs.sourceKind + && lhs.gitInfo == rhs.gitInfo + && lhs.createdAt == rhs.createdAt + && lhs.updatedAt == rhs.updatedAt + && lhs.recencyAt == rhs.recencyAt + && lhs.status == rhs.status + && lhs.ephemeral == rhs.ephemeral + && lhs.turns == rhs.turns + } + + public init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + sessionID: String? = nil, + parentThreadID: CodexThreadID? = nil, + source: CodexThreadSessionSource? = nil, + sourceKind: CodexThreadSourceKind? = nil, + gitInfo: CodexThreadGitInfo? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + recencyAt: Date? = nil, + status: CodexThreadStatus? = nil, + ephemeral: Bool? = nil, + turns: [CodexTurnSnapshot]? = nil + ) { + self.init( + id: id, + workspace: workspace, + name: name, + preview: preview, + modelProvider: modelProvider, + sessionID: sessionID, + parentThreadID: parentThreadID, + source: source, + sourceKind: sourceKind, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns, + turnItemsAreAuthoritative: true, + presentFields: Self.presentFields( + sessionID: sessionID, + parentThreadID: parentThreadID, + workspace: workspace, + name: name, + preview: preview, + modelProvider: modelProvider, + source: source, + sourceKind: sourceKind, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns + ) + ) + } + + package init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + sessionID: String? = nil, + parentThreadID: CodexThreadID? = nil, + source: CodexThreadSessionSource? = nil, + sourceKind: CodexThreadSourceKind? = nil, + gitInfo: CodexThreadGitInfo? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + recencyAt: Date? = nil, + status: CodexThreadStatus? = nil, + ephemeral: Bool? = nil, + turns: [CodexTurnSnapshot]? = nil, + turnItemsAreAuthoritative: Bool, + presentFields: Set? = nil + ) { + precondition( + source == nil || sourceKind == nil || source?.sourceKind == sourceKind, + "An exact thread source and its compatibility kind must agree." + ) + self.id = id + self.sessionID = sessionID + self.parentThreadID = parentThreadID + self.workspace = workspace + self.name = name + self.preview = preview + self.modelProvider = modelProvider + self.sourceStorage = source + self.sourceKindFallback = source == nil ? sourceKind : nil + self.gitInfo = gitInfo + self.createdAt = createdAt + self.updatedAt = updatedAt + self.recencyAt = recencyAt + self.status = status + self.ephemeral = ephemeral + self.turns = turns + self.turnItemsAreAuthoritative = turnItemsAreAuthoritative + && (turns?.allSatisfy(\.itemsAreAuthoritative) ?? false) + self.presentFields = presentFields ?? Self.presentFields( + sessionID: sessionID, + parentThreadID: parentThreadID, + workspace: workspace, + name: name, + preview: preview, + modelProvider: modelProvider, + source: source, + sourceKind: sourceKind, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns + ) + } + + package func hasField(_ field: Field) -> Bool { + presentFields.contains(field) + } + + private static func presentFields( + sessionID: String?, + parentThreadID: CodexThreadID?, + workspace: URL?, + name: String?, + preview: String?, + modelProvider: String?, + source: CodexThreadSessionSource?, + sourceKind: CodexThreadSourceKind?, + gitInfo: CodexThreadGitInfo?, + createdAt: Date?, + updatedAt: Date?, + recencyAt: Date?, + status: CodexThreadStatus?, + ephemeral: Bool?, + turns: [CodexTurnSnapshot]? + ) -> Set { + var fields: Set = [] + if sessionID != nil { + fields.insert(.sessionID) + } + if parentThreadID != nil { + fields.insert(.parentThreadID) + } + if workspace != nil { + fields.insert(.workspace) + } + if name != nil { + fields.insert(.name) + } + if preview != nil { + fields.insert(.preview) + } + if modelProvider != nil { + fields.insert(.modelProvider) + } + if source != nil { + fields.insert(.source) + } else if sourceKind != nil { + fields.insert(.sourceKind) + } + if gitInfo != nil { + fields.insert(.gitInfo) + } + if createdAt != nil { + fields.insert(.createdAt) + } + if updatedAt != nil { + fields.insert(.updatedAt) + } + if recencyAt != nil { + fields.insert(.recencyAt) + } + if status != nil { + fields.insert(.status) + } + if ephemeral != nil { + fields.insert(.ephemeral) + } + if turns != nil { + fields.insert(.turns) + } + return fields + } +} + +public struct CodexTurnSnapshot: Identifiable, Equatable, Sendable { + public enum State: Equatable, Sendable { + case inProgress + case completed + case interrupted + case failed(CodexTurnError) + case unknown(rawValue: String, error: CodexTurnError?) + } + + public var id: CodexTurnID + public var state: State + public var itemsLoadState: CodexTurnItemsLoadState + public var items: [CodexThreadItem] + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? + + public var status: CodexTurnStatus { + switch state { + case .inProgress: + .inProgress + case .completed: + .completed + case .interrupted: + .interrupted + case .failed: + .failed + case .unknown(let rawValue, _): + .unknown(rawValue: rawValue) + } + } + + public var error: CodexTurnError? { + switch state { + case .failed(let error): + error + case .unknown(_, let error): + error + case .inProgress, .completed, .interrupted: + nil + } + } + + public init( + id: CodexTurnID, + state: State, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexThreadItem] = [], + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) { + self.id = id + self.state = state + self.itemsLoadState = itemsLoadState + self.items = items + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration + } + + package var itemsAreAuthoritative: Bool { + itemsLoadState == .full + } +} + +public enum CodexTurnItemsLoadState: String, Codable, Equatable, Sendable { + case notLoaded + case summary + case full +} + +public struct CodexTurnQuery: Equatable, Sendable { + public var cursor: String? + public var limit: Int? + public var sortDirection: CodexSortDirection? + public var itemsLoadState: CodexTurnItemsLoadState? + + public init( + cursor: String? = nil, + limit: Int? = nil, + sortDirection: CodexSortDirection? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil + ) { + self.cursor = cursor + self.limit = limit + self.sortDirection = sortDirection + self.itemsLoadState = itemsLoadState + } +} + +public struct CodexTurnPage: Equatable, Sendable { + public var turns: [CodexTurnSnapshot] + public var nextCursor: String? + public var backwardsCursor: String? + + public init( + turns: [CodexTurnSnapshot], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.turns = turns + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } +} + +public struct CodexThreadQuery: Equatable, Sendable { + public var archived: Bool? + public var cursor: String? + public var workspaces: [URL]? { + get { + _workspaces + } + set { + _workspaces = Self.normalizedWorkspaces(newValue) + } + } + public var workspace: URL? { + get { + workspaces?.first + } + set { + _workspaces = Self.normalizedWorkspaces(newValue.map { [$0] }) + } + } + public var limit: Int? + public var searchTerm: String? + public var modelProviders: [String]? + public var sortDirection: CodexSortDirection? + public var sortKey: CodexThreadSortKey? + public var sourceKinds: [CodexThreadSourceKind]? + public var useStateDBOnly: Bool? + private var _workspaces: [URL]? + + public init( + archived: Bool? = nil, + cursor: String? = nil, + workspace: URL? = nil, + workspaces: [URL]? = nil, + limit: Int? = nil, + searchTerm: String? = nil, + modelProviders: [String]? = nil, + sortDirection: CodexSortDirection? = nil, + sortKey: CodexThreadSortKey? = nil, + sourceKinds: [CodexThreadSourceKind]? = nil, + useStateDBOnly: Bool? = nil + ) { + self.archived = archived + self.cursor = cursor + self._workspaces = Self.normalizedWorkspaces(workspaces ?? workspace.map { [$0] }) + self.limit = limit + self.searchTerm = searchTerm + self.modelProviders = modelProviders + self.sortDirection = sortDirection + self.sortKey = sortKey + self.sourceKinds = sourceKinds + self.useStateDBOnly = useStateDBOnly + } + + private static func normalizedWorkspaces(_ workspaces: [URL]?) -> [URL]? { + guard let workspaces else { + return nil + } + let normalized = workspaces.filter { $0.path.isEmpty == false } + return normalized.isEmpty ? nil : normalized + } +} + +/// Sort direction for thread list queries. +public enum CodexSortDirection: String, Codable, Equatable, Sendable { + case ascending = "asc" + case descending = "desc" +} + +/// Sort key for thread list queries. +public enum CodexThreadSortKey: String, Codable, Equatable, Sendable { + case createdAt = "created_at" + case updatedAt = "updated_at" + case recencyAt = "recency_at" +} + +/// Source-kind filter for thread list queries. +public struct CodexThreadSourceKind: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let cli = Self(rawValue: "cli") + public static let vscode = Self(rawValue: "vscode") + public static let exec = Self(rawValue: "exec") + public static let appServer = Self(rawValue: "appServer") + public static let subAgent = Self(rawValue: "subAgent") + public static let subAgentReview = Self(rawValue: "subAgentReview") + public static let subAgentCompact = Self(rawValue: "subAgentCompact") + public static let subAgentThreadSpawn = Self(rawValue: "subAgentThreadSpawn") + public static let subAgentOther = Self(rawValue: "subAgentOther") + public static let unknown = Self(rawValue: "unknown") +} + +extension CodexThreadSourceKind: Codable {} + +public struct CodexThreadPage: Equatable, Sendable { + public var threads: [CodexThreadSnapshot] + public var nextCursor: String? + public var backwardsCursor: String? + + public init( + threads: [CodexThreadSnapshot], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.threads = threads + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } +} + +public struct CodexTranscript: Equatable, Sendable { + public var items: [CodexThreadItem] + + public init(items: [CodexThreadItem] = []) { + self.items = items + } + + public var messages: [CodexMessage] { + items.compactMap(\.message) + } + + public var finalAnswer: String? { + var fallback: String? + for message in messages.reversed() where message.role == .assistant { + guard message.text.isEmpty == false else { + continue + } + if message.phase == .finalAnswer { + return message.text + } + if message.phase == nil, fallback == nil { + fallback = message.text + } + } + return fallback + } + + public var reviewOutputText: String? { + for item in items.reversed() where item.kind == .exitedReviewMode { + if let text = item.text, text.isEmpty == false { + return text + } + } + return nil + } + + public var responseText: String? { + messages.reversed().first { + $0.role == .assistant && $0.text.isEmpty == false + }?.text + } +} + +public struct CodexThreadItem: Identifiable, Equatable, Sendable { + public enum Origin: Hashable, Sendable { + case currentV2Item + case reviewRolloutAssistant + } + + public enum SemanticTarget: Hashable, Sendable { + case exitedReviewMode + } + + public enum SemanticRelation: Hashable, Sendable { + case companionOf(SemanticTarget) + } + + public enum Kind: Hashable, Sendable { + case userMessage + case agentMessage + case enteredReviewMode + case exitedReviewMode + case plan + case reasoning + case commandExecution + case fileChange + case mcpToolCall + case dynamicToolCall + case collabAgentToolCall + case subAgentActivity + case webSearch + case imageView + case sleep + case imageGeneration + case contextCompaction + case diagnostic + case error + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "userMessage": + self = .userMessage + case "agentMessage": + self = .agentMessage + case "enteredReviewMode": + self = .enteredReviewMode + case "exitedReviewMode": + self = .exitedReviewMode + case "plan": + self = .plan + case "reasoning": + self = .reasoning + case "commandExecution": + self = .commandExecution + case "fileChange": + self = .fileChange + case "mcpToolCall": + self = .mcpToolCall + case "dynamicToolCall": + self = .dynamicToolCall + case "collabAgentToolCall": + self = .collabAgentToolCall + case "subAgentActivity": + self = .subAgentActivity + case "webSearch": + self = .webSearch + case "imageView": + self = .imageView + case "sleep": + self = .sleep + case "imageGeneration": + self = .imageGeneration + case "contextCompaction": + self = .contextCompaction + case "diagnostic": + self = .diagnostic + case "error": + self = .error + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .userMessage: + "userMessage" + case .agentMessage: + "agentMessage" + case .enteredReviewMode: + "enteredReviewMode" + case .exitedReviewMode: + "exitedReviewMode" + case .plan: + "plan" + case .reasoning: + "reasoning" + case .commandExecution: + "commandExecution" + case .fileChange: + "fileChange" + case .mcpToolCall: + "mcpToolCall" + case .dynamicToolCall: + "dynamicToolCall" + case .collabAgentToolCall: + "collabAgentToolCall" + case .subAgentActivity: + "subAgentActivity" + case .webSearch: + "webSearch" + case .imageView: + "imageView" + case .sleep: + "sleep" + case .imageGeneration: + "imageGeneration" + case .contextCompaction: + "contextCompaction" + case .diagnostic: + "diagnostic" + case .error: + "error" + case .unknown(let rawValue): + rawValue + } + } + } + + public enum Content: Equatable, Sendable { + case message(CodexMessage) + case plan(String) + case reasoning(CodexReasoning) + case command(CodexCommand) + case fileChange(CodexFileChange) + case toolCall(CodexToolCall) + case contextCompaction(String?) + case diagnostic(String) + case log(String) + case unknown(CodexRawItem) + } + + public var id: String + public var kind: Kind + public var content: Content + public private(set) var origin: Origin + public private(set) var semanticRelation: SemanticRelation? + public var rawPayload: Data? + + public init( + id: String, + kind: Kind, + content: Content, + rawPayload: Data? = nil + ) { + let semanticMetadata = Self.semanticMetadata(id: id, kind: kind) + self.id = id + self.kind = kind + self.content = content + self.origin = semanticMetadata.origin + self.semanticRelation = semanticMetadata.relation + self.rawPayload = rawPayload + } + + package init( + id: String, + kind: Kind, + content: Content, + origin: Origin, + semanticRelation: SemanticRelation?, + rawPayload: Data? = nil + ) { + self.id = id + self.kind = kind + self.content = content + self.origin = origin + self.semanticRelation = semanticRelation + self.rawPayload = rawPayload + } + + private static func semanticMetadata( + id: String, + kind: Kind + ) -> (origin: Origin, relation: SemanticRelation?) { + guard kind == .agentMessage, id == "review_rollout_assistant" else { + return (.currentV2Item, nil) + } + return (.reviewRolloutAssistant, .companionOf(.exitedReviewMode)) + } + + public var text: String? { + switch content { + case .message(let message): + message.text + case .plan(let text), .diagnostic(let text), .log(let text): + text + case .reasoning(let reasoning): + reasoning.text + case .command(let command): + command.output ?? command.command + case .fileChange(let fileChange): + fileChange.output ?? fileChange.path + case .toolCall(let toolCall): + toolCall.result ?? toolCall.error ?? toolCall.name + case .contextCompaction(let text): + text + case .unknown(let raw): + raw.text + } + } + + public var message: CodexMessage? { + if case .message(let message) = content { + return message + } + return nil + } +} + +public struct CodexReasoning: Equatable, Sendable { + public var summary: [String] { + didSet { + summary = Self.normalizedFragments(summary) + } + } + public var content: [String] { + didSet { + content = Self.normalizedFragments(content) + } + } + + public static let empty = Self(summary: [], content: []) + + public init(summary: [String] = [], content: [String] = []) { + self.summary = Self.normalizedFragments(summary) + self.content = Self.normalizedFragments(content) + } + + public init(summary: String, content: String? = nil) { + self.init( + summary: [summary], + content: content.map { [$0] } ?? [] + ) + } + + public init(content: String) { + self.init(summary: [], content: [content]) + } + + public var text: String { + let preferred = summary.isEmpty ? content : summary + return preferred.joined(separator: "\n\n") + } + + private static func normalizedFragments(_ fragments: [String]) -> [String] { + var seen = Set() + var normalized: [String] = [] + normalized.reserveCapacity(fragments.count) + for fragment in fragments { + let key = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard key.isEmpty == false, seen.insert(key).inserted else { + continue + } + normalized.append(fragment) + } + return normalized + } +} + +public struct CodexMessage: Identifiable, Equatable, Sendable { + public enum Role: Equatable, Sendable { + case user + case assistant + case system + case tool + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "user": + self = .user + case "assistant", "agent": + self = .assistant + case "system": + self = .system + case "tool": + self = .tool + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .user: + "user" + case .assistant: + "assistant" + case .system: + "system" + case .tool: + "tool" + case .unknown(let rawValue): + rawValue + } + } + } + + public var id: String + public var role: Role + public var phase: CodexMessagePhase? + public var text: String + + public init( + id: String, + role: Role, + phase: CodexMessagePhase? = nil, + text: String + ) { + self.id = id + self.role = role + self.phase = phase + self.text = text + } +} + +public enum CodexMessagePhase: Equatable, Sendable { + case commentary + case finalAnswer + case unknown(String) + + public init(rawValue: String) { + switch rawValue { + case "commentary": + self = .commentary + case "final_answer", "finalAnswer": + self = .finalAnswer + case let rawValue: + self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .commentary: + "commentary" + case .finalAnswer: + "final_answer" + case .unknown(let rawValue): + rawValue + } + } +} + +public struct CodexCommand: Equatable, Sendable { + public struct Source: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let agent = Self(rawValue: "agent") + public static let user = Self(rawValue: "user") + } + + public struct Action: Equatable, Sendable { + public enum Kind: String, Equatable, Sendable { + case read + case listFiles + case search + case unknown + } + + public var kind: Kind + public var command: String? + public var name: String? + public var path: String? + public var query: String? + + public init( + kind: Kind, + command: String? = nil, + name: String? = nil, + path: String? = nil, + query: String? = nil + ) { + self.kind = kind + self.command = command + self.name = name + self.path = path + self.query = query + } + } + + public var command: String + public var cwd: String? + public var output: String? + public var exitCode: Int? + public var status: CodexTurnStatus? + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? + public var processID: String? + public var source: Source? + public var commandActions: [Action] + + public var durationMilliseconds: Int? { + guard let duration else { + return nil + } + let components = duration.components + let milliseconds = components.seconds * 1_000 + components.attoseconds / 1_000_000_000_000_000 + guard milliseconds >= 0, milliseconds <= Int.max else { + return nil + } + return Int(milliseconds) + } + + public init( + command: String, + cwd: String? = nil, + output: String? = nil, + exitCode: Int? = nil, + status: CodexTurnStatus? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil, + processID: String? = nil, + source: Source? = nil, + commandActions: [Action] = [] + ) { + self.command = command + self.cwd = cwd + self.output = output + self.exitCode = exitCode + self.status = status + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration + self.processID = processID + self.source = source + self.commandActions = commandActions + } +} + +public struct CodexFileChange: Equatable, Sendable { + public var path: String? + public var output: String? + public var status: CodexTurnStatus? + + public init(path: String? = nil, output: String? = nil, status: CodexTurnStatus? = nil) { + self.path = path + self.output = output + self.status = status + } +} + +public struct CodexFileUpdateChange: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case add + case delete + case update(movePath: String?) + } + + public var path: String + public var kind: Kind + public var diff: String + + public init(path: String, kind: Kind, diff: String) { + self.path = path + self.kind = kind + self.diff = diff + } +} + +public struct CodexToolCall: Equatable, Sendable { + public var namespace: String? + public var server: String? + public var name: String? + public var arguments: String? + public var result: String? + public var error: String? + public var status: CodexTurnStatus? + + public init( + namespace: String? = nil, + server: String? = nil, + name: String? = nil, + arguments: String? = nil, + result: String? = nil, + error: String? = nil, + status: CodexTurnStatus? = nil + ) { + self.namespace = namespace + self.server = server + self.name = name + self.arguments = arguments + self.result = result + self.error = error + self.status = status + } +} + +public struct CodexRawItem: Equatable, Sendable { + public var rawType: String + public var text: String? + public var payload: Data? + + public init(rawType: String, text: String? = nil, payload: Data? = nil) { + self.rawType = rawType + self.text = text + self.payload = payload + } +} + +public enum CodexTurnStatus: Equatable, Sendable { + case inProgress + case completed + case interrupted + case failed + case unknown(rawValue: String) + + public init(rawValue: String) { + switch rawValue { + case "inProgress", "running", "started": + self = .inProgress + case "completed": + self = .completed + case "interrupted": + self = .interrupted + case "failed": + self = .failed + case let rawValue: + self = .unknown(rawValue: rawValue) + } + } + + public var rawValue: String { + switch self { + case .inProgress: + "inProgress" + case .completed: + "completed" + case .interrupted: + "interrupted" + case .failed: + "failed" + case .unknown(let rawValue): + rawValue + } + } +} + +public struct CodexResponse: Identifiable, Equatable, Sendable { + public var turnID: CodexTurnID + public var transcript: CodexTranscript + /// Completeness of the transcript items carried by this response. + public var transcriptItemsLoadState: CodexTurnItemsLoadState + public var usage: CodexTokenUsage? + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? + + public var id: CodexTurnID { + turnID + } + + public init( + turnID: CodexTurnID, + usage: CodexTokenUsage? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) { + self.init( + turnID: turnID, + transcript: .init(), + transcriptItemsLoadState: .notLoaded, + usage: usage, + startedAt: startedAt, + completedAt: completedAt, + duration: duration + ) + } + + public init( + turnID: CodexTurnID, + transcript: CodexTranscript, + transcriptItemsLoadState: CodexTurnItemsLoadState = .full, + usage: CodexTokenUsage? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) { + self.turnID = turnID + self.transcript = transcript + self.transcriptItemsLoadState = transcriptItemsLoadState + self.usage = usage + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration + } +} + +public enum CodexErrorInfo: Equatable, Sendable { + case contextWindowExceeded + case sessionBudgetExceeded + case usageLimitExceeded + case serverOverloaded + case cyberPolicy + case httpConnectionFailed(httpStatusCode: UInt16?) + case responseStreamConnectionFailed(httpStatusCode: UInt16?) + case internalServerError + case unauthorized + case badRequest + case threadRollbackFailed + case sandboxError + case responseStreamDisconnected(httpStatusCode: UInt16?) + case responseTooManyFailedAttempts(httpStatusCode: UInt16?) + case activeTurnNotSteerable(turnKind: String) + case other + case unknown(rawValue: String) +} + +public struct CodexTurnError: Error, Equatable, LocalizedError, Sendable { + public var message: String + public var info: CodexErrorInfo? + public var additionalDetails: String? + + public init( + message: String, + info: CodexErrorInfo? = nil, + additionalDetails: String? = nil + ) { + self.message = message + self.info = info + self.additionalDetails = additionalDetails + } + + public var errorDescription: String? { message } +} + +/// A nonterminal error notification emitted while a turn is running. +public struct CodexTurnDiagnostic: Equatable, Sendable { + /// The app-server error payload. + public var error: CodexTurnError + + /// Whether the app-server will retry the operation that produced this diagnostic. + public var willRetry: Bool + + public init(error: CodexTurnError, willRetry: Bool) { + self.error = error + self.willRetry = willRetry + } +} + +public enum CodexTurnOutcome: Equatable, Sendable { + case completed(CodexResponse) + case interrupted(CodexResponse) + case failed(CodexFailedTurn) + case invalidTerminalStatus( + rawStatus: String, + error: CodexTurnError?, + response: CodexResponse + ) + + public var response: CodexResponse { + switch self { + case .completed(let response), .interrupted(let response), + .invalidTerminalStatus(_, _, let response): + response + case .failed(let failedTurn): + failedTurn.response + } + } +} + +public struct CodexFailedTurn: Equatable, Sendable { + public var response: CodexResponse + public var error: CodexTurnError + + package init(response: CodexResponse, error: CodexTurnError) { + self.response = response + self.error = error + } +} + +/// The turn cancelled by an app-server control request. +public struct CodexTurnCancellation: Equatable, Sendable { + /// The thread that owns the cancelled turn. + public var threadID: CodexThreadID + + /// The cancelled turn, when the app-server reported one. + public var turnID: CodexTurnID? + + public init(threadID: CodexThreadID, turnID: CodexTurnID?) { + self.threadID = threadID + self.turnID = turnID + } + + package init(threadID: String, turnID: String?) { + self.threadID = .init(rawValue: threadID) + self.turnID = turnID.flatMap { value in + value.isEmpty ? nil : CodexTurnID(rawValue: value) + } + } +} + +package struct CodexResponseStream: AsyncSequence, Sendable { + package struct Snapshot: Equatable, Sendable { + package var turnID: CodexTurnID + package var content: String? + package var transcript: CodexTranscript + package var usage: CodexTokenUsage? + package var response: CodexResponse? + + package init( + turnID: CodexTurnID, + content: String? = nil, + transcript: CodexTranscript = .init(), + usage: CodexTokenUsage? = nil, + response: CodexResponse? = nil + ) { + self.turnID = turnID + self.content = content + self.transcript = transcript + self.usage = usage + self.response = response + } + } + + package let turn: CodexTurn + + package init(turn: CodexTurn) { + self.turn = turn + } + + package func makeAsyncIterator() -> Iterator { + Iterator( + turn: turn, + progress: turn.progress.makeAsyncIterator() + ) + } + + package func collect(timeout: Duration? = nil) async throws -> CodexTurnOutcome { + if let timeout { + return try await turn.client.runTurnWithDeadline( + turnID: turn.id, + duration: timeout + ) { + try await turn.result() + } + } + return try await turn.result() + } + + /// Cancels the running response. + /// + /// - Returns: The turn that the app-server actually cancelled. This can + /// differ from the stream's original turn when the app-server reports a + /// newer active turn. + @discardableResult + package func cancel() async throws -> CodexTurnCancellation { + try await turn.interrupt() + } + + @discardableResult + package func cancel( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? + ) async throws -> CodexTurnCancellation { + try await turn.interrupt(willCancelActiveTurn: willCancelActiveTurn) + } + + package func steer(with prompt: CodexPrompt) async throws { + try await turn.steer(with: prompt) + } + + package func steer(with prompt: String) async throws { + try await steer(with: CodexPrompt(prompt)) + } + + package func steer(@CodexPromptBuilder prompt: () throws -> CodexPrompt) async throws { + try await steer(with: try prompt()) + } + + package func waitForCancelledResponse( + _ cancellation: CodexTurnCancellation, + preparedState: TurnGenerationHandleState? = nil + ) async throws -> CodexTurnOutcome { + let cancelledTurn = try await cancelledTurn( + for: cancellation, + preparedState: preparedState + ) + for try await event in cancelledTurn.events { + switch event { + case .terminal(let outcome): + switch outcome { + case .interrupted, .completed, .invalidTerminalStatus: + return outcome + case .failed(let failedTurn): + throw failedTurn.error + } + case .started, .snapshot, .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta, .diagnostic, + .tokenUsageUpdated, .unknown: + continue + } + } + throw CodexAppServerError.connectionTerminated(.transportFailure(.closed)) + } + + package func closeConnection() async { + await turn.state.closeConnection() + } + + private func cancelledTurn( + for cancellation: CodexTurnCancellation, + preparedState: TurnGenerationHandleState? + ) async throws -> CodexTurn { + let cancelledTurnID = cancellation.turnID ?? turn.id + if cancelledTurnID == turn.id { + if let preparedState { + precondition( + preparedState === turn.state, + "The original cancellation generation must preserve its state identity." + ) + } + return turn + } + let state: TurnGenerationHandleState + if let preparedState { + state = preparedState + } else { + let connectionLease = try await turn.state.connectionLeaseForSiblingGeneration() + state = await turn.turnReplayStore.restoreGeneration( + turnID: cancelledTurnID, + initialSnapshot: .init( + id: cancelledTurnID, + state: .inProgress, + itemsLoadState: .notLoaded + ), + connectionLease: connectionLease + ) + } + return CodexTurn( + id: cancelledTurnID, + threadID: cancellation.threadID, + client: turn.client, + router: turn.router, + turnReplayStore: turn.turnReplayStore, + state: state + ) + } + + package struct Iterator: AsyncIteratorProtocol { + private let turn: CodexTurn + private var progress: CodexTurnProgressSequence.Iterator + + fileprivate init( + turn: CodexTurn, + progress: CodexTurnProgressSequence.Iterator + ) { + self.turn = turn + self.progress = progress + } + + package mutating func next() async throws -> Snapshot? { + guard let progress = try await progress.next() else { + try Task.checkCancellation() + return nil + } + switch progress { + case .running(let transcript, let usage): + return Snapshot( + turnID: turn.id, + content: transcript.responseText, + transcript: transcript, + usage: usage + ) + case .terminal(let outcome): + let response = outcome.response + return Snapshot( + turnID: turn.id, + content: response.transcript.responseText, + transcript: response.transcript, + usage: response.usage, + response: response + ) + } + } + } +} + +public struct CodexTokenUsage: Equatable, Sendable { + public var inputTokens: Int? + public var outputTokens: Int? + public var totalTokens: Int? + public var cachedInputTokens: Int? + public var reasoningOutputTokens: Int? + public var modelContextWindow: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + totalTokens: Int? = nil, + cachedInputTokens: Int? = nil, + reasoningOutputTokens: Int? = nil, + modelContextWindow: Int? = nil + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.cachedInputTokens = cachedInputTokens + self.reasoningOutputTokens = reasoningOutputTokens + self.modelContextWindow = modelContextWindow + } +} + +package struct CodexMessageDelta: Equatable, Sendable { + package var text: String + package let itemID: String + package var phase: CodexMessagePhase? + package var currentItem: CodexThreadItem? + + package init(text: String, itemID: String, phase: CodexMessagePhase? = nil) { + Self.preconditionValidItemID(itemID) + self.text = text + self.itemID = itemID + self.phase = phase + currentItem = nil + } + + package init( + text: String, + itemID: String, + phase: CodexMessagePhase?, + currentItem: CodexThreadItem + ) { + Self.preconditionValidItemID(itemID) + self.text = text + self.itemID = itemID + self.phase = phase + self.currentItem = currentItem + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.text == rhs.text + && lhs.itemID == rhs.itemID + && lhs.phase == rhs.phase + } + + private static func preconditionValidItemID(_ itemID: String) { + precondition( + itemID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + "CodexMessageDelta.itemID must not be empty or whitespace." + ) + } +} + +/// A reasoning summary or raw reasoning text part emitted by app-server. +package struct CodexReasoningPart: Identifiable, Equatable, Sendable { + package enum Kind: Equatable, Sendable { + case summary + case text + } + + package var itemID: String + package var kind: Kind + package var index: Int + package var currentItem: CodexThreadItem? + + package var id: String { + switch kind { + case .summary: + "\(itemID):summary:\(index)" + case .text: + "\(itemID):content:\(index)" + } + } + + package init(itemID: String, kind: Kind, index: Int) { + self.itemID = itemID + self.kind = kind + self.index = index + currentItem = nil + } + + package init( + itemID: String, + kind: Kind, + index: Int, + currentItem: CodexThreadItem + ) { + self.itemID = itemID + self.kind = kind + self.index = index + self.currentItem = currentItem + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.itemID == rhs.itemID + && lhs.kind == rhs.kind + && lhs.index == rhs.index + } +} + +/// Incremental text for a reasoning summary or raw reasoning text part. +package struct CodexReasoningDelta: Identifiable, Equatable, Sendable { + package var part: CodexReasoningPart + package var delta: String + package var currentItem: CodexThreadItem? + + package var id: String { + part.id + } + + package init(part: CodexReasoningPart, delta: String) { + self.part = part + self.delta = delta + currentItem = nil + } + + package init( + part: CodexReasoningPart, + delta: String, + currentItem: CodexThreadItem + ) { + self.part = part + self.delta = delta + self.currentItem = currentItem + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.part == rhs.part && lhs.delta == rhs.delta + } +} + +package enum CodexTurnEvent: Equatable, Sendable { + case started(CodexTurnID) + case snapshot(CodexTurnSnapshot) + case itemStarted(CodexThreadItem) + case itemUpdated(CodexThreadItem) + case itemCompleted(CodexThreadItem) + case message(CodexMessage) + case messageDelta(CodexMessageDelta) + case reasoningSummaryPartAdded(CodexReasoningPart) + case reasoningDelta(CodexReasoningDelta) + case diagnostic(CodexTurnDiagnostic) + case tokenUsageUpdated(CodexTokenUsage) + case terminal(CodexTurnOutcome) + case unknown(CodexRawNotification) +} + +package enum CodexThreadEvent: Equatable, Sendable { + case turnStarted(CodexTurnID) + case snapshot(CodexTurnSnapshot) + case terminal(CodexTurnOutcome) + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case message(CodexMessage, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?) + case reasoningSummaryPartAdded(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) + case diagnostic(CodexTurnDiagnostic, turnID: CodexTurnID) + case tokenUsageUpdated(CodexTokenUsage, turnID: CodexTurnID?) + case statusChanged(CodexThreadStatus) + case closed + case unknown(CodexRawNotification) +} + +package enum CodexThreadLogEntry: Identifiable, Equatable, Sendable { + package enum Phase: Equatable, Sendable { + case started + case updated + case completed + case delta + case diagnostic + } + + case itemStarted(CodexThreadItem, turnID: CodexTurnID?) + case itemUpdated(CodexThreadItem, turnID: CodexTurnID?) + case itemCompleted(CodexThreadItem, turnID: CodexTurnID?) + case messageDelta(CodexMessageDelta, turnID: CodexTurnID?, id: String) + case reasoningPartStarted(CodexReasoningPart, turnID: CodexTurnID?) + case reasoningDelta(CodexReasoningDelta, turnID: CodexTurnID?) + case diagnostic(CodexTurnDiagnostic, turnID: CodexTurnID, id: String) + + package var id: String { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item.id + case .messageDelta(_, _, let id): + id + case .reasoningPartStarted(let part, _): + part.id + case .reasoningDelta(let delta, _): + delta.id + case .diagnostic(_, _, let id): + id + } + } + + package var turnID: CodexTurnID? { + switch self { + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .messageDelta(_, let turnID, _), + .reasoningPartStarted(_, let turnID), .reasoningDelta(_, let turnID): + turnID + case .diagnostic(_, let turnID, _): + turnID + } + } + + package var phase: Phase { + switch self { + case .itemStarted, .reasoningPartStarted: + .started + case .itemUpdated: + .updated + case .itemCompleted: + .completed + case .messageDelta, .reasoningDelta: + .delta + case .diagnostic: + .diagnostic + } + } + + package var item: CodexThreadItem? { + switch self { + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + item + case .reasoningPartStarted(let part, _): + .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)) + case .messageDelta, .reasoningDelta: + nil + case .diagnostic: + nil + } + } + + package var messageDelta: CodexMessageDelta? { + if case .messageDelta(let delta, _, _) = self { + return delta + } + return nil + } + + package var reasoningDelta: CodexReasoningDelta? { + if case .reasoningDelta(let delta, _) = self { + return delta + } + return nil + } + + package var diagnostic: CodexTurnDiagnostic? { + if case .diagnostic(let diagnostic, _, _) = self { + return diagnostic + } + return nil + } +} + +public struct CodexThreadActiveFlag: RawRepresentable, Hashable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let waitingOnApproval = Self(rawValue: "waitingOnApproval") + public static let waitingOnUserInput = Self(rawValue: "waitingOnUserInput") +} + +public enum CodexThreadStatus: Equatable, Sendable { + case notLoaded + case idle + case systemError + case active(activeFlags: [CodexThreadActiveFlag]) + case unknown(rawValue: String) + + public init(rawValue: String) { + switch rawValue { + case "notLoaded": + self = .notLoaded + case "idle": + self = .idle + case "systemError": + self = .systemError + case "active": + self = .active(activeFlags: []) + case let rawValue: + self = .unknown(rawValue: rawValue) + } + } + + package init(type: String, activeFlags: [String]? = nil) { + switch type { + case "active": + self = .active(activeFlags: activeFlags?.map(CodexThreadActiveFlag.init(rawValue:)) ?? []) + default: + self.init(rawValue: type) + } + } + + public var rawValue: String { + switch self { + case .notLoaded: + "notLoaded" + case .idle: + "idle" + case .systemError: + "systemError" + case .active: + "active" + case .unknown(let rawValue): + rawValue + } + } + + public var isActive: Bool { + if case .active = self { + return true + } + return false + } + + public var activeFlags: [CodexThreadActiveFlag] { + if case .active(let activeFlags) = self { + return activeFlags + } + return [] + } +} + +package enum CodexTurnProgress: Equatable, Sendable { + case running(transcript: CodexTranscript, usage: CodexTokenUsage?) + case terminal(CodexTurnOutcome) +} + +public struct CodexRawNotification: Equatable, Sendable { + public var method: String + public var params: Data + public var threadID: CodexThreadID? + public var turnID: CodexTurnID? + + public init( + method: String, + params: Data, + threadID: CodexThreadID? = nil, + turnID: CodexTurnID? = nil + ) { + self.method = method + self.params = params + self.threadID = threadID + self.turnID = turnID + } +} + +public struct CodexConfiguration: Equatable, Sendable { + public var model: String? + public var reviewModel: String? + public var reasoningEffort: CodexReasoningEffort? + public var serviceTier: String? + + public init( + model: String? = nil, + reviewModel: String? = nil, + reasoningEffort: CodexReasoningEffort? = nil, + serviceTier: String? = nil + ) { + self.model = model + self.reviewModel = reviewModel + self.reasoningEffort = reasoningEffort + self.serviceTier = serviceTier + } +} + +public struct CodexConfigurationPatch: Equatable, Sendable { + public private(set) var reviewModel: String? + public private(set) var reasoningEffort: CodexReasoningEffort? + public private(set) var serviceTier: String? + public private(set) var updatesReviewModel: Bool + public private(set) var updatesReasoningEffort: Bool + public private(set) var updatesServiceTier: Bool + + public init() { + self.reviewModel = nil + self.reasoningEffort = nil + self.serviceTier = nil + self.updatesReviewModel = false + self.updatesReasoningEffort = false + self.updatesServiceTier = false + } + + public mutating func setReviewModel(_ reviewModel: String?) { + self.reviewModel = reviewModel + self.updatesReviewModel = true + } + + public mutating func setReasoningEffort(_ reasoningEffort: CodexReasoningEffort?) { + self.reasoningEffort = reasoningEffort + self.updatesReasoningEffort = true + } + + public mutating func setServiceTier(_ serviceTier: String?) { + self.serviceTier = serviceTier + self.updatesServiceTier = true + } + + package init( + reviewModel: String? = nil, + reasoningEffort: CodexReasoningEffort? = nil, + serviceTier: String? = nil, + updatesReviewModel: Bool = false, + updatesReasoningEffort: Bool = false, + updatesServiceTier: Bool = false + ) { + self.reviewModel = reviewModel + self.reasoningEffort = reasoningEffort + self.serviceTier = serviceTier + self.updatesReviewModel = updatesReviewModel + self.updatesReasoningEffort = updatesReasoningEffort + self.updatesServiceTier = updatesServiceTier + } +} + +public struct CodexRateLimits: Equatable, Sendable { + public var planType: String? + public var windows: [CodexRateLimitWindow] + + public init(planType: String? = nil, windows: [CodexRateLimitWindow] = []) { + self.planType = planType + self.windows = windows + } +} + +public struct CodexRateLimitWindow: Equatable, Sendable { + public var windowDurationMinutes: Int + public var usedPercent: Int + public var resetsAt: Date? + + public init(windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date? = nil) { + self.windowDurationMinutes = windowDurationMinutes + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +package extension CodexRateLimits { + init(appServer response: AppServerAPI.Account.RateLimits.Response) { + self.init( + planType: response.codexPlanType, + windows: response.codexRateLimitWindows.map { + .init( + windowDurationMinutes: $0.windowDurationMinutes, + usedPercent: $0.usedPercent, + resetsAt: $0.resetsAt + ) + } + ) + } +} + +public struct CodexModel: Codable, Identifiable, Equatable, Sendable { + public struct ReasoningOption: Codable, Equatable, Sendable { + public var reasoningEffort: CodexReasoningEffort + public var description: String + + public init(reasoningEffort: CodexReasoningEffort, description: String) { + self.reasoningEffort = reasoningEffort + self.description = description + } + } + + private struct ServiceTier: Decodable { + let id: String + } + + public var id: String + public var model: String + public var displayName: String + public var hidden: Bool + public var supportedReasoningEfforts: [ReasoningOption] + public var defaultReasoningEffort: CodexReasoningEffort? + public var supportedServiceTiers: [String] + public var isDefault: Bool + + private enum CodingKeys: String, CodingKey { + case id + case model + case displayName + case hidden + case supportedReasoningEfforts + case defaultReasoningEffort + case supportedServiceTiers = "additionalSpeedTiers" + case serviceTiers + case isDefault + } + + public init( + id: String, + model: String, + displayName: String, + hidden: Bool = false, + supportedReasoningEfforts: [ReasoningOption] = [], + defaultReasoningEffort: CodexReasoningEffort? = nil, + supportedServiceTiers: [String] = [], + isDefault: Bool = false + ) { + self.id = id + self.model = model + self.displayName = displayName + self.hidden = hidden + self.supportedReasoningEfforts = supportedReasoningEfforts + self.defaultReasoningEffort = defaultReasoningEffort + self.supportedServiceTiers = supportedServiceTiers + self.isDefault = isDefault + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.model = try container.decode(String.self, forKey: .model) + self.displayName = try container.decode(String.self, forKey: .displayName) + self.hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) ?? false + self.supportedReasoningEfforts = + try container.decodeIfPresent( + [ReasoningOption].self, + forKey: .supportedReasoningEfforts + ) ?? [] + self.defaultReasoningEffort = try container.decodeIfPresent( + CodexReasoningEffort.self, forKey: .defaultReasoningEffort) + let additionalSpeedTiers = + try container.decodeIfPresent([String].self, forKey: .supportedServiceTiers) ?? [] + let serviceTierIDs = + try container.decodeIfPresent([ServiceTier].self, forKey: .serviceTiers)?.map(\.id) + ?? [] + self.supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTierIDs)).sorted() + self.isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(model, forKey: .model) + try container.encode(displayName, forKey: .displayName) + try container.encode(hidden, forKey: .hidden) + try container.encode(supportedReasoningEfforts, forKey: .supportedReasoningEfforts) + try container.encodeIfPresent(defaultReasoningEffort, forKey: .defaultReasoningEffort) + try container.encode(supportedServiceTiers, forKey: .supportedServiceTiers) + try container.encode(isDefault, forKey: .isDefault) + } +} + +public struct CodexAccount: Identifiable, Equatable, Sendable { + public enum Kind: String, Codable, Equatable, Sendable { + case chatGPT = "chatgpt" + case apiKey + case amazonBedrock + } + + public var id: String + public var kind: Kind + public var label: String + public var planType: String? + + public init(id: String, kind: Kind, label: String, planType: String? = nil) { + self.id = id + self.kind = kind + self.label = label + self.planType = planType + } +} + +/// The result of an app-server account login completion notification. +package struct CodexLoginCompletion: Equatable, Sendable { + /// The app-server login identifier, when the notification is scoped to a login flow. + package var loginID: CodexLoginHandle.ID? + + /// Whether the login completed successfully. + package var success: Bool + + /// The server-provided failure message when `success` is false. + package var error: String? + + package init(loginID: CodexLoginHandle.ID? = nil, success: Bool, error: String? = nil) { + self.loginID = loginID + self.success = success + self.error = error + } +} + +/// A typed account-related notification emitted by Codex app-server. +public enum CodexAccountEvent: Equatable, Sendable { + /// The active account changed or was refreshed. + case accountUpdated + + /// Account rate-limit information changed. + case rateLimitsUpdated(CodexRateLimits) + + /// A known account notification arrived with a shape this SDK could not decode. + case malformed(method: String, message: String) + + /// A notification outside the current account event surface. + case unknown(CodexRawNotification) +} + +public enum CodexLoginOutcome: Equatable, Sendable { + case succeeded + case authenticationCommittedNeedsConnectionReconciliation(CodexLoginReconciliationReason) + case failed(message: String?) + case cancelled +} + +public enum CodexLoginReconciliationReason: Equatable, Sendable { + case connectionTerminated(CodexConnectionTermination) + case accountReadinessDeadlineExceeded(Duration) + case chatGPTAccountUnavailableAfterSuccess + case malformedAccountUpdateAfterSuccess(CodexMalformedNotification) + case cancelOutcomeUnknown(CodexRequestFailure?) +} + +public struct CodexLoginHandle: Identifiable, Equatable, Sendable { + public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + } + + public let id: ID + public let authenticationURL: URL + private let state: LoginState + + package init(state: LoginState, id: ID, authenticationURL: URL) { + self.state = state + self.id = id + self.authenticationURL = authenticationURL + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id && lhs.authenticationURL == rhs.authenticationURL + } + + public func result() async throws -> CodexLoginOutcome { + try await state.result() + } + + @discardableResult + public func cancel(acknowledgementTimeout: Duration? = nil) async throws -> CodexLoginOutcome { + try await state.cancel(acknowledgementTimeout: acknowledgementTimeout) + } + + public func closeConnection() async { + await state.closeConnection() + } +} + +/// Why an API key was rejected before an authentication request was sent. +public enum CodexAPIKeyValidationFailure: Equatable, Sendable { + /// The key is empty or contains only whitespace. + case empty + + /// The key has leading or trailing whitespace. + case surroundingWhitespace +} + +/// Why the result of a written authentication request could not be confirmed. +public enum CodexAuthenticationOutcomeUnknownReason: Equatable, Sendable { + /// The app-server connection terminated before a correlated response was accepted. + case connectionTerminated + + /// The authentication request exceeded its configured deadline after being written. + case deadlineExceeded(Duration) + + /// The transport ended after the request was written but before its result was confirmed. + case transportEnded + + /// The app-server returned bytes that were not a valid login response. + case invalidResponse + + /// The app-server returned a valid response for a different login method. + case unexpectedResponse +} + +public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { + case launch(CodexLaunchFailure) + case request(CodexRequestFailure) + case connectionTerminated(CodexConnectionTermination) + case turnDeadlineExceeded(turnID: CodexTurnID, duration: Duration) + case malformedNotification(CodexMalformedNotification) + case reviewRestartUnavailable(CodexReviewRestartToken.ID) + case loginAlreadyInProgress + case invalidAPIKey(CodexAPIKeyValidationFailure) + case authenticationOutcomeUnknown(CodexAuthenticationOutcomeUnknownReason) + + public var errorDescription: String? { + switch self { + case .launch(let failure): + failure.localizedDescription + case .request(let failure): + failure.localizedDescription + case .connectionTerminated(let termination): + termination.errorDescription + case .turnDeadlineExceeded(let turnID, let duration): + "Turn \(turnID.rawValue) did not reach a terminal outcome within \(duration)." + case .malformedNotification(let failure): + failure.localizedDescription + case .reviewRestartUnavailable(let tokenID): + "Prepared review restart is no longer available for token \(tokenID)." + case .loginAlreadyInProgress: + "A ChatGPT login is already in progress." + case .invalidAPIKey(.empty): + "The API key must not be empty." + case .invalidAPIKey(.surroundingWhitespace): + "The API key must not contain leading or trailing whitespace." + case .authenticationOutcomeUnknown(.connectionTerminated): + "The app-server connection terminated before the API-key login result was confirmed. Reconcile account state before retrying." + case .authenticationOutcomeUnknown(.deadlineExceeded(let duration)): + "The written API-key login request exceeded its deadline of \(duration) before its result was confirmed. Reconcile account state before retrying." + case .authenticationOutcomeUnknown(.transportEnded): + "The transport ended before the written API-key login result was confirmed. Reconcile account state before retrying." + case .authenticationOutcomeUnknown(.invalidResponse): + "The app-server returned an invalid API-key login response. Reconcile account state before retrying." + case .authenticationOutcomeUnknown(.unexpectedResponse): + "The app-server returned an unexpected login response. Reconcile account state before retrying." + } + } +} + +public struct CodexRequestFailure: Error, Equatable, LocalizedError, Sendable { + public enum Kind: Equatable, Sendable { + case encode(message: String) + case write(CodexTransportFailure) + case transport(CodexTransportFailure) + case server(CodexServerError) + case invalidResponse(expectedType: String, message: String, rawData: Data?) + case deadlineExceeded(Duration) + case overloadRetryExhausted(last: CodexServerError, attempts: Int) + } + + public var requestID: Int + public var method: String + public var purpose: CodexRequestPurpose + public var kind: Kind + + package init( + requestID: Int, + method: String, + purpose: CodexRequestPurpose, + kind: Kind + ) { + self.requestID = requestID + self.method = method + self.purpose = purpose + self.kind = kind + } + + public var errorDescription: String? { + let prefix = "JSON-RPC request \(requestID) (\(method))" + return switch kind { + case .encode(let message): + "\(prefix) could not be encoded: \(message)" + case .write(let failure): + "\(prefix) could not be written: \(failure.localizedDescription)" + case .transport(let failure): + "\(prefix) failed in transport: \(failure.localizedDescription)" + case .server(let error): + "\(prefix) was rejected by the server: \(error.message)" + case .invalidResponse(_, let message, _): + "\(prefix) returned an invalid response: \(message)" + case .deadlineExceeded(let duration): + "\(prefix) exceeded its deadline of \(duration)." + case .overloadRetryExhausted(let last, let attempts): + "\(prefix) remained overloaded after \(attempts) attempts: \(last.message)" + } + } +} + +public enum CodexRequestPurpose: Equatable, Sendable { + case handshake + case operation(String) +} + +public struct CodexServerError: Error, Equatable, LocalizedError, Sendable { + public var code: Int + public var message: String + public var data: Data? + public var turnError: CodexTurnError? + + public init( + code: Int, + message: String, + data: Data? = nil, + turnError: CodexTurnError? = nil + ) { + self.code = code + self.message = message + self.data = data + self.turnError = turnError + } + + public var errorDescription: String? { message } +} + +public enum CodexTransportFailure: Error, Equatable, LocalizedError, Sendable { + case closed + case io(errno: Int32?, message: String) + case framing(message: String, rawData: Data?) + case protocolViolation(message: String, rawData: Data?) + case contractViolation(message: String) + + public var errorDescription: String? { + switch self { + case .closed: + "The Codex app-server transport is closed." + case .io(_, let message), .framing(let message, _), + .protocolViolation(let message, _), .contractViolation(let message): + message + } + } +} + +public enum CodexLaunchFailure: Error, Equatable, LocalizedError, Sendable { + case executableNotFound(command: String, searchedPath: String?) + case scaffold(path: String, message: String) + case spawn(executable: String, errno: Int32?, message: String) + + public var errorDescription: String? { + switch self { + case .executableNotFound(let command, let searchedPath): + if let searchedPath, searchedPath.isEmpty == false { + "Unable to locate \(command) executable in PATH: \(searchedPath)" + } else { + "Unable to locate \(command) executable." + } + case .scaffold(let path, let message): + "Unable to prepare Codex home at \(path): \(message)" + case .spawn(let executable, _, let message): + "Unable to launch \(executable): \(message)" + } + } +} + +public struct CodexMalformedNotification: Error, Equatable, LocalizedError, Sendable { + public var method: String + public var message: String + public var rawData: Data? + + package init(method: String, message: String, rawData: Data?) { + self.method = method + self.message = message + self.rawData = rawData + } + + public var errorDescription: String? { + "Malformed \(method) notification: \(message)" + } +} + +public enum CodexConnectionTermination: Equatable, Sendable { + case closedByCaller + case transportFailure(CodexTransportFailure) + case processExited(status: Int32?) + + package var errorDescription: String { + switch self { + case .closedByCaller: + "The Codex app-server connection was closed by the caller." + case .transportFailure(let failure): + "The Codex app-server connection terminated: \(failure.localizedDescription)" + case .processExited(let status): + if let status { + "The Codex app-server process exited with status \(status)." + } else { + "The Codex app-server process exited." + } + } + } +} + +package struct CodexAppServerClock: Sendable { + package var now: @Sendable () -> Date + + package init(now: @escaping @Sendable () -> Date = { Date() }) { + self.now = now + } +} + +package struct CodexDeadlineClock: Sendable { + package var sleep: @Sendable (Duration) async throws -> Void + + package init(sleep: @escaping @Sendable (Duration) async throws -> Void) { + self.sleep = sleep + } + + package static var continuous: Self { + .init { try await Task.sleep(for: $0) } + } +} diff --git a/Sources/CodexAppServerKit/CodexItemReducer.swift b/Sources/CodexAppServerKit/CodexItemReducer.swift new file mode 100644 index 00000000..858b9855 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexItemReducer.swift @@ -0,0 +1,376 @@ +import Foundation + +package struct CodexItemReducer { + package enum Mutation: Equatable, Sendable { + case started(CodexThreadItem) + case completed(CodexThreadItem) + case agentMessageDelta(itemID: String, delta: String) + case planDelta(itemID: String, delta: String) + case reasoningSummaryPartAdded(itemID: String, index: Int) + case reasoningSummaryDelta(itemID: String, index: Int, delta: String) + case reasoningTextDelta(itemID: String, index: Int, delta: String) + case commandOutputDelta(itemID: String, delta: String) + case filePatchSnapshot(itemID: String, output: String?) + case mcpProgress(itemID: String, message: String) + case turnDiagnostic(CodexTurnDiagnostic) + } + + package enum ContractError: Error, Equatable, Sendable, LocalizedError { + case missingItemID + case missingBaseItem(turnID: CodexTurnID, itemID: String) + case unexpectedItemKind( + turnID: CodexTurnID, + itemID: String, + expected: CodexThreadItem.Kind, + actual: CodexThreadItem.Kind + ) + case completedItemKindChanged( + turnID: CodexTurnID, + itemID: String, + started: CodexThreadItem.Kind, + completed: CodexThreadItem.Kind + ) + case invalidReasoningIndex(itemID: String, index: Int) + + package var errorDescription: String? { + switch self { + case .missingItemID: + "Current-v2 item notification is missing its required item ID." + case .missingBaseItem(let turnID, let itemID): + "Item delta for \(itemID) has no base item in turn \(turnID.rawValue)." + case .unexpectedItemKind(let turnID, let itemID, let expected, let actual): + "Item delta for \(itemID) in turn \(turnID.rawValue) expected \(expected.rawValue), got \(actual.rawValue)." + case .completedItemKindChanged(let turnID, let itemID, let started, let completed): + "Completed item \(itemID) in turn \(turnID.rawValue) changed kind from \(started.rawValue) to \(completed.rawValue)." + case .invalidReasoningIndex(let itemID, let index): + "Reasoning item \(itemID) used invalid part index \(index)." + } + } + } + + private struct ItemKey: Hashable { + var turnID: CodexTurnID + var itemID: String + } + + private struct ItemState { + var item: CodexThreadItem + var reasoningSummary: [String]? + var reasoningContent: [String]? + + init(item: CodexThreadItem) { + self.item = item + if case .reasoning(let reasoning) = item.content { + reasoningSummary = reasoning.summary + reasoningContent = reasoning.content + } + } + + mutating func replace(with item: CodexThreadItem) { + self = .init(item: item) + } + + mutating func updateReasoningProjection() { + item.content = .reasoning(.init( + summary: reasoningSummary ?? [], + content: reasoningContent ?? [] + )) + } + } + + private var stateByItemKey: [ItemKey: ItemState] = [:] + + package init() {} + + package mutating func reduce( + _ mutation: Mutation, + turnID: CodexTurnID + ) throws -> CodexTurnEvent { + if case .turnDiagnostic(let diagnostic) = mutation { + return .diagnostic(diagnostic) + } + + let item = try apply(mutation, turnID: turnID) + switch mutation { + case .started: + return .itemStarted(item) + case .completed: + return .itemCompleted(item) + case .agentMessageDelta(let itemID, let delta): + return .messageDelta(.init( + text: delta, + itemID: itemID, + phase: item.message?.phase, + currentItem: item + )) + case .reasoningSummaryPartAdded(let itemID, let index): + return .reasoningSummaryPartAdded(.init( + itemID: itemID, + kind: .summary, + index: index, + currentItem: item + )) + case .reasoningSummaryDelta(let itemID, let index, let delta): + let part = CodexReasoningPart(itemID: itemID, kind: .summary, index: index) + return .reasoningDelta(.init(part: part, delta: delta, currentItem: item)) + case .reasoningTextDelta(let itemID, let index, let delta): + let part = CodexReasoningPart(itemID: itemID, kind: .text, index: index) + return .reasoningDelta(.init(part: part, delta: delta, currentItem: item)) + case .planDelta, .commandOutputDelta, .filePatchSnapshot, .mcpProgress: + return .itemUpdated(item) + case .turnDiagnostic: + preconditionFailure("A turn diagnostic must return before item reduction.") + } + } + + package mutating func seed(_ turns: [CodexTurnSnapshot]?) { + for turn in turns ?? [] { + guard case .inProgress = turn.state else { + continue + } + for item in turn.items where Self.hasValidID(item.id) { + let key = ItemKey(turnID: turn.id, itemID: item.id) + guard stateByItemKey[key] == nil else { + continue + } + stateByItemKey[key] = .init(item: item) + } + } + } + + @discardableResult + package mutating func apply( + _ mutation: Mutation, + turnID: CodexTurnID + ) throws -> CodexThreadItem { + switch mutation { + case .started(let item): + try Self.requireItemID(item.id) + stateByItemKey[.init(turnID: turnID, itemID: item.id)] = .init(item: item) + return item + + case .completed(let item): + try Self.requireItemID(item.id) + let key = ItemKey(turnID: turnID, itemID: item.id) + guard let base = stateByItemKey[key]?.item else { + stateByItemKey[key] = .init(item: item) + return item + } + guard base.kind == item.kind else { + throw ContractError.completedItemKindChanged( + turnID: turnID, + itemID: item.id, + started: base.kind, + completed: item.kind + ) + } + let merged = Self.mergeCompleted(item, preservingMetadataFrom: base) + stateByItemKey[key]?.replace(with: merged) + return merged + + case .agentMessageDelta(let itemID, let delta): + return try update(itemID: itemID, turnID: turnID, expectedKind: .agentMessage) { + guard case .message(var message) = $0.item.content else { + return false + } + message.text += delta + $0.item.content = .message(message) + return true + } + + case .planDelta(let itemID, let delta): + return try update(itemID: itemID, turnID: turnID, expectedKind: .plan) { + guard case .plan(let text) = $0.item.content else { + return false + } + $0.item.content = .plan(text + delta) + return true + } + + case .reasoningSummaryPartAdded(let itemID, let index): + guard index >= 0 else { + throw ContractError.invalidReasoningIndex(itemID: itemID, index: index) + } + return try update(itemID: itemID, turnID: turnID, expectedKind: .reasoning) { + guard case .reasoning = $0.item.content else { + return false + } + Self.grow(&$0.reasoningSummary, through: index) + $0.updateReasoningProjection() + return true + } + + case .reasoningSummaryDelta(let itemID, let index, let delta): + guard index >= 0 else { + throw ContractError.invalidReasoningIndex(itemID: itemID, index: index) + } + return try update(itemID: itemID, turnID: turnID, expectedKind: .reasoning) { + guard case .reasoning = $0.item.content else { + return false + } + Self.grow(&$0.reasoningSummary, through: index) + $0.reasoningSummary?[index] += delta + $0.updateReasoningProjection() + return true + } + + case .reasoningTextDelta(let itemID, let index, let delta): + guard index >= 0 else { + throw ContractError.invalidReasoningIndex(itemID: itemID, index: index) + } + return try update(itemID: itemID, turnID: turnID, expectedKind: .reasoning) { + guard case .reasoning = $0.item.content else { + return false + } + Self.grow(&$0.reasoningContent, through: index) + $0.reasoningContent?[index] += delta + $0.updateReasoningProjection() + return true + } + + case .commandOutputDelta(let itemID, let delta): + return try update(itemID: itemID, turnID: turnID, expectedKind: .commandExecution) { + guard case .command(var command) = $0.item.content else { + return false + } + command.output = (command.output ?? "") + delta + $0.item.content = .command(command) + return true + } + + case .filePatchSnapshot(let itemID, let output): + return try update(itemID: itemID, turnID: turnID, expectedKind: .fileChange) { + guard case .fileChange(var fileChange) = $0.item.content else { + return false + } + fileChange.output = output + $0.item.content = .fileChange(fileChange) + return true + } + + case .mcpProgress(let itemID, let message): + return try update(itemID: itemID, turnID: turnID, expectedKind: .mcpToolCall) { + guard case .toolCall(var toolCall) = $0.item.content else { + return false + } + toolCall.result = message + $0.item.content = .toolCall(toolCall) + return true + } + + case .turnDiagnostic: + preconditionFailure("Use reduce(_:turnID:) for turn diagnostics.") + } + } + + package func item(turnID: CodexTurnID, itemID: String) -> CodexThreadItem? { + stateByItemKey[.init(turnID: turnID, itemID: itemID)]?.item + } + + package mutating func release(turnID: CodexTurnID) { + stateByItemKey = stateByItemKey.filter { $0.key.turnID != turnID } + } + + package mutating func releaseAll() { + stateByItemKey.removeAll(keepingCapacity: false) + } + + private mutating func update( + itemID: String, + turnID: CodexTurnID, + expectedKind: CodexThreadItem.Kind, + body: (inout ItemState) -> Bool + ) throws -> CodexThreadItem { + try Self.requireItemID(itemID) + let key = ItemKey(turnID: turnID, itemID: itemID) + guard var state = stateByItemKey[key] else { + throw ContractError.missingBaseItem(turnID: turnID, itemID: itemID) + } + guard state.item.kind == expectedKind, body(&state) else { + throw ContractError.unexpectedItemKind( + turnID: turnID, + itemID: itemID, + expected: expectedKind, + actual: state.item.kind + ) + } + stateByItemKey[key] = state + return state.item + } + + private static func mergeCompleted( + _ completed: CodexThreadItem, + preservingMetadataFrom started: CodexThreadItem + ) -> CodexThreadItem { + var merged = completed + merged.rawPayload = completed.rawPayload ?? started.rawPayload + switch (started.content, completed.content) { + case (.message(let initial), .message(var final)): + final.phase = final.phase ?? initial.phase + merged.content = .message(final) + + case (.command(let initial), .command(var final)): + if final.command.isEmpty { + final.command = initial.command + } + final.cwd = final.cwd ?? initial.cwd + final.output = final.output ?? initial.output + final.exitCode = final.exitCode ?? initial.exitCode + final.status = final.status ?? initial.status + final.startedAt = final.startedAt ?? initial.startedAt + final.completedAt = final.completedAt ?? initial.completedAt + final.duration = final.duration ?? initial.duration + final.processID = final.processID ?? initial.processID + final.source = final.source ?? initial.source + if final.commandActions.isEmpty { + final.commandActions = initial.commandActions + } + merged.content = .command(final) + + case (.fileChange(let initial), .fileChange(var final)): + final.path = final.path ?? initial.path + final.output = final.output ?? initial.output + final.status = final.status ?? initial.status + merged.content = .fileChange(final) + + case (.toolCall(let initial), .toolCall(var final)): + final.namespace = final.namespace ?? initial.namespace + final.server = final.server ?? initial.server + final.name = final.name ?? initial.name + final.arguments = final.arguments ?? initial.arguments + final.result = final.result ?? initial.result + final.error = final.error ?? initial.error + final.status = final.status ?? initial.status + merged.content = .toolCall(final) + + default: + break + } + return CodexThreadItem( + id: merged.id, + kind: merged.kind, + content: merged.content, + origin: started.origin, + semanticRelation: started.semanticRelation, + rawPayload: merged.rawPayload + ) + } + + private static func grow(_ values: inout [String]?, through index: Int) { + var buffer = values ?? [] + if buffer.count <= index { + buffer.append(contentsOf: repeatElement("", count: index - buffer.count + 1)) + } + values = buffer + } + + private static func requireItemID(_ itemID: String) throws { + guard hasValidID(itemID) else { + throw ContractError.missingItemID + } + } + + private static func hasValidID(_ itemID: String) -> Bool { + itemID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } +} diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift new file mode 100644 index 00000000..5dd31274 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -0,0 +1,997 @@ +import Foundation +import Synchronization + +extension CodexThread { + /// Thread-scoped events emitted by the app-server. + /// + /// The sequence replays buffered events for this thread before yielding + /// live notifications. It finishes when the app-server reports the thread + /// as closed or when the app-server connection closes. + package var events: CodexThreadEventSequence { + router.events(for: id) + } + + package func beginEventGeneration() { + router.resetThreadEventGeneration(id) + } + + package func makeCurrentGenerationEventStream() -> CodexThreadEventSequence { + router.events(for: id) + } + + /// Agent messages emitted by this thread. + /// + /// This sequence is derived from `events` and is useful when callers only + /// need the conversational output rather than every item lifecycle event. + package var messages: CodexThreadMessageSequence { + .init(events: events) + } + + /// Incremental transcript snapshots derived from this thread's events. + package var transcriptUpdates: CodexThreadTranscriptSequence { + .init(events: events) + } + + /// Log-oriented item events for this thread. + /// + /// This includes command, tool, file-change, diagnostic, and unknown + /// app-server items that are useful for output logs or progress views. + package var logEntries: CodexThreadLogSequence { + .init(events: events) + } + + /// Sends a prompt to the thread and waits for the final response. + /// + /// Use `streamResponse(to:options:)` when the caller needs incremental + /// progress, transcript updates, steering, or cancellation. + /// + /// - Parameters: + /// - prompt: The structured prompt to send. + /// - options: Per-turn generation options. + /// - Returns: The completed response collected from app-server events. + public func respond( + to prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil + ) async throws -> CodexTurnOutcome { + switch try await collectResponse(to: prompt, options: options, timeout: timeout) { + case .outcome(let outcome): + return outcome + case .cancelled: + throw CancellationError() + } + } + + package func collectResponse( + to prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil + ) async throws -> CodexResponseCollectionResult { + let cancellationRecorder = CodexResponseCancellationRecorder() + let stream: CodexResponseStream + do { + stream = try await streamResponse( + to: prompt, + options: options, + cancellationRecorder: cancellationRecorder + ) + } catch is CancellationError { + guard let outcome = cancellationRecorder.outcome else { + throw CancellationError() + } + return .cancelled(outcome) + } + do { + let outcome = try await stream.collect(timeout: timeout) + try Task.checkCancellation() + return .outcome(outcome) + } catch is CancellationError { + return .cancelled(try await interruptAndAwaitTerminal(stream)) + } catch let error as CodexAppServerError { + if case .turnDeadlineExceeded = error { + _ = try await interruptAndAwaitTerminal(stream) + } + throw error + } + } + + /// Sends a text prompt to the thread and waits for the final response. + public func respond( + to prompt: String, + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil + ) async throws -> CodexTurnOutcome { + try await respond(to: CodexPrompt(prompt), options: options, timeout: timeout) + } + + /// Builds a structured prompt, sends it to the thread, and waits for the final response. + public func respond( + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil, + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexTurnOutcome { + try await respond(to: try prompt(), options: options, timeout: timeout) + } + + /// Sends a prompt and returns a live response stream. + /// + /// The returned stream exposes events, progress, transcript updates, the + /// collected result, and Codex-specific controls such as steer and cancel. + /// + /// - Parameters: + /// - prompt: The structured prompt to send. + /// - options: Per-turn generation options. + /// - Returns: A live response stream for the started turn. + package func streamResponse( + to prompt: CodexPrompt, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + try await streamResponse( + to: prompt, + options: options, + cancellationRecorder: nil + ) + } + + private func streamResponse( + to prompt: CodexPrompt, + options: CodexGenerationOptions, + cancellationRecorder: CodexResponseCancellationRecorder? + ) async throws -> CodexResponseStream { + let turn = try await startTurn( + prompt, + options: options, + cancellationRecorder: cancellationRecorder + ) + return .init(turn: turn) + } + + /// Sends a text prompt and returns a live response stream. + package func streamResponse( + to prompt: String, + options: CodexGenerationOptions = .init() + ) async throws -> CodexResponseStream { + try await streamResponse(to: CodexPrompt(prompt), options: options) + } + + /// Builds a structured prompt, sends it, and returns a live response stream. + package func streamResponse( + options: CodexGenerationOptions = .init(), + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexResponseStream { + try await streamResponse(to: try prompt(), options: options) + } + + /// Starts a Codex code review in this thread. + /// + /// The returned session owns the source, active review-thread, and turn + /// identities and exposes the review's typed terminal outcome. Native UI + /// consumers observe the corresponding chat through CodexDataKit instead + /// of constructing a second model graph from transport events. + /// + /// - Parameters: + /// - target: The repository changes or custom instructions to review. + /// - delivery: Whether the app-server should run the review inline or in a detached review thread. + /// - Returns: A live review session. + public func startReview( + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline + ) async throws -> CodexReviewSession { + try await startReview( + target: target, + delivery: delivery, + onPostWriteCancellation: { review in + try await cleanupCancelledReviewSession(review) + } + ) + } + + package func startReview( + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline, + onPostWriteCancellation: @escaping @Sendable (CodexReviewSession) async throws -> Void + ) async throws -> CodexReviewSession { + let state = TurnGenerationHandleState(connectionLease: connectionLease) + let pending = await turnReplayStore.registerPendingOperation( + kind: .review(sourceThreadID: id, delivery: delivery), + state: state + ) + if delivery == .detached { + await router.registerDetachedReviewRoutingAttempt(pending) + } + do { + let response: AppServerAPI.Review.Start.Response = try await withThreadEventGeneration( + id, + router: router, + generationOperation: .reviewStart(delivery: delivery) + ) { generation in + try await client.send( + AppServerAPI.Review.Start.Request( + params: .init(threadID: id.rawValue, target: target, delivery: delivery) + ), + reconcileResponse: { response in + let responseThreadID = CodexThreadID( + rawValue: response.reviewThreadID + ) + switch delivery { + case .inline: + guard responseThreadID == id else { + throw CodexTransportFailure.contractViolation( + message: "An inline review must run on its source thread." + ) + } + case .detached: + guard responseThreadID != id else { + throw CodexTransportFailure.contractViolation( + message: "A detached review must run on a new review thread." + ) + } + } + try await router.reconcileReviewStartResponse( + pending, + reviewThreadID: responseThreadID, + initialSnapshot: CodexAppServer.turnSnapshots( + from: [response.turn] + )[0], + generation: generation + ) + }, + onWriteAccepted: { + generation.acceptWrite() + pending.acceptWrite() + }, + onResponseRejected: { + generation.rejectResponse() + pending.rejectAcceptedWrite() + try await router.rejectDetachedReviewRoutingAttemptResponse(pending) + }, + onPostWriteCancellation: { response in + let review = await reviewSession( + from: response, + state: state + ) + try await onPostWriteCancellation(review) + } + ) + } + return await reviewSession(from: response, state: state) + } catch let operationError { + do { + try await router.cancelDetachedReviewRoutingAttempt(pending) + } catch { + await finishPendingTurnOperation( + pending, + state: state, + store: turnReplayStore + ) + throw error + } + await finishPendingTurnOperation( + pending, + state: state, + store: turnReplayStore + ) + throw operationError + } + } + + private func reviewSession( + from response: AppServerAPI.Review.Start.Response, + state: TurnGenerationHandleState + ) async -> CodexReviewSession { + let responseReviewThreadID = CodexThreadID(rawValue: response.reviewThreadID) + let detachedReviewThreadID = responseReviewThreadID == id ? nil : responseReviewThreadID + let turnID = CodexTurnID(rawValue: response.turnID) + let initialTurn = CodexAppServer.turnSnapshots(from: [response.turn])[0] + let identity = CodexReviewIdentity( + threadID: id, + turnID: turnID, + reviewThreadID: detachedReviewThreadID, + model: detachedReviewThreadID == nil ? model : nil + ) + return await reviewSession( + identity, + initialTurn: initialTurn, + state: state + ) + } + + private func cleanupCancelledReviewSession( + _ review: CodexReviewSession + ) async throws { + _ = try await review.interruptAndAwaitTerminalAcknowledgement() + guard review.reviewThreadID != id else { + return + } + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: review.reviewThreadID.rawValue) + ) + ) + } + + package func reviewSession( + _ identity: CodexReviewIdentity, + model: String? = nil, + initialTurn: CodexTurnSnapshot? = nil, + state proposedState: TurnGenerationHandleState? = nil + ) async -> CodexReviewSession { + let reviewThreadID = identity.activeTurnThreadID + let initialTurn = initialTurn ?? CodexTurnSnapshot( + id: identity.turnID, + state: .inProgress, + itemsLoadState: .notLoaded + ) + let state: TurnGenerationHandleState + if let proposedState { + state = proposedState + } else { + state = await turnReplayStore.restoreGeneration( + turnID: identity.turnID, + initialSnapshot: initialTurn, + connectionLease: connectionLease + ) + } + if await state.snapshot() == .live { + await router.adoptThreadEventGeneration( + reviewThreadID, + including: identity.turnID + ) + } + let model = model ?? identity.model + let turn = CodexTurn( + id: identity.turnID, + threadID: reviewThreadID, + client: client, + router: router, + turnReplayStore: turnReplayStore, + state: state + ) + return .init( + threadID: identity.sourceThreadID, + turnID: turn.id, + reviewThreadID: reviewThreadID, + model: model, + initialTurn: initialTurn, + response: .init(turn: turn) + ) + } + + /// Cancels the currently active turn for this thread. + /// + /// Use this when the caller has a resumed `CodexThread` handle but no + /// in-memory response or review session object, such as after restoring a + /// persisted running operation. + /// + /// - Parameters: + /// - expectedTurnID: The turn the caller expects to cancel. When the + /// app-server reports a newer active turn, the returned cancellation + /// identifies the actual cancelled turn. Pass `nil` to submit an + /// unguarded startup interrupt; that response does not reveal the + /// active turn identity. + /// - willCancelActiveTurn: Optional hook invoked before retrying an + /// cancellation for a newer active turn reported by app-server. + /// - Returns: The expected or redirected turn when known, or a cancellation + /// with no turn identity for an unguarded startup interrupt. + @discardableResult + public func cancelActiveTurn( + expectedTurnID: CodexTurnID? = nil, + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? = nil + ) async throws -> CodexTurnCancellation { + try await interruptCodexTurn( + threadID: id, + turnID: expectedTurnID, + client: client, + willCancelActiveTurn: willCancelActiveTurn + ) + } + + package func startTurn( + _ prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + cancellationRecorder: CodexResponseCancellationRecorder? = nil + ) async throws -> CodexTurn { + try await startCodexTurn( + threadID: id, + prompt: prompt, + options: options, + client: client, + router: router, + connectionLease: connectionLease, + cancellationRecorder: cancellationRecorder + ) + } + + /// Reads the current thread snapshot. + /// + /// - Parameter includeTurns: Whether to include turn summaries in the snapshot. + /// - Returns: The current app-server snapshot for this thread. + public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot { + let response = try await client.send( + AppServerAPI.Thread.Read.Request( + params: .init(threadID: id.rawValue, includeTurns: includeTurns) + )) + let snapshot = CodexAppServer.threadSnapshot( + from: response.thread, + includesTurns: includeTurns + ) + await router.seedTurns(snapshot.turns, threadID: id) + if let currentTurn = snapshot.turns?.last { + await router.seedCurrentTurnSnapshot(currentTurn, threadID: id) + } + return snapshot + } + + /// Lists this thread's turns. + /// + /// This endpoint can include the app-server's current in-memory active turn + /// snapshot, so it is the preferred source for UI detail panes that need an + /// initial transcript before consuming live item events. + public func listTurns(_ query: CodexTurnQuery = .init()) async throws -> CodexTurnPage { + let response = try await client.send( + AppServerAPI.Thread.Turns.List.Request( + params: .init( + threadID: id.rawValue, + cursor: query.cursor, + limit: query.limit, + sortDirection: query.sortDirection, + itemsLoadState: query.itemsLoadState + ) + )) + let turns = CodexAppServer.turnSnapshots(from: response.data) + await router.seedTurns(turns, threadID: id) + let currentTurn: CodexTurnSnapshot? + switch query.sortDirection ?? .descending { + case .ascending where response.nextCursor == nil: + currentTurn = turns.last + case .descending where query.cursor == nil: + currentTurn = turns.first + default: + currentTurn = nil + } + if let currentTurn { + await router.seedCurrentTurnSnapshot(currentTurn, threadID: id) + } + return .init( + turns: turns, + nextCursor: response.nextCursor, + backwardsCursor: response.backwardsCursor + ) + } + + /// Renames this thread. + /// + /// - Parameter name: The new user-visible thread name. + public func rename(to name: String) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Name.Set.Request( + params: .init(threadID: id.rawValue, name: name) + )) + } + + /// Starts app-server context compaction for this thread. + public func compact() async throws { + let _: EmptyResponse = try await withThreadEventGeneration(id, router: router) { generation in + try await client.send( + AppServerAPI.Thread.Compact.Start.Request( + params: .init(threadID: id.rawValue) + ), + onWriteAccepted: generation.acceptWrite, + onResponseRejected: generation.rejectResponse, + onResponseAccepted: generation.acceptResponse + ) + } + } + + /// Archives this thread. + public func archive() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Archive.Request( + params: .init(threadID: id.rawValue) + )) + } + + /// Restores this thread from the archive. + /// + /// - Returns: The restored thread snapshot. + public func unarchive() async throws -> CodexThreadSnapshot { + let response = try await client.send( + AppServerAPI.Thread.Unarchive.Request( + params: .init(threadID: id.rawValue) + )) + let snapshot = CodexAppServer.threadSnapshot(from: response.thread, includesTurns: false) + await router.seedTurns(snapshot.turns, threadID: id) + return snapshot + } + + /// Rolls this thread back by the specified number of turns. + /// + /// - Parameter turnCount: The number of latest turns to remove. + package func rollback(turnCount: Int = 1) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Rollback.Request( + params: .init(threadID: id.rawValue, numTurns: turnCount) + )) + } + + /// Permanently deletes this thread. + public func delete() async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Thread.Delete.Request( + params: .init(threadID: id.rawValue) + )) + } + + /// Closes the app-server connection shared by this thread. + public func closeConnection() async { + await connectionLease.closeConnection() + } +} + +package enum CodexResponseCollectionResult: Sendable { + case outcome(CodexTurnOutcome) + case cancelled(CodexTurnOutcome) +} + +package final class CodexResponseCancellationRecorder: Sendable { + private let storedOutcome = Mutex(nil) + + package var outcome: CodexTurnOutcome? { + storedOutcome.withLock { $0 } + } + + package func record(_ outcome: CodexTurnOutcome) { + storedOutcome.withLock { storedOutcome in + precondition( + storedOutcome == nil, + "A response collection can record only one cancellation outcome." + ) + storedOutcome = outcome + } + } +} + +package func interruptAndAwaitTerminal(_ stream: CodexResponseStream) async throws -> CodexTurnOutcome { + let cleanup = Task { + try await stream.turn.interruptAndAwaitTerminalAcknowledgement().outcome + } + return try await cleanup.value +} + +package func startCodexTurn( + threadID: CodexThreadID, + prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + client: AppServerClient, + router: CodexAppServerNotificationRouter, + connectionLease: AppServerConnectionLease, + cancellationRecorder: CodexResponseCancellationRecorder? = nil +) async throws -> CodexTurn { + let store = router.turnReplayStore + let state = TurnGenerationHandleState(connectionLease: connectionLease) + let pending = await store.registerPendingOperation( + kind: .turn(threadID: threadID), + state: state + ) + do { + let response: AppServerAPI.Turn.Start.Response = try await withThreadEventGeneration( + threadID, + router: router + ) { generation in + try await client.send( + AppServerAPI.Turn.Start.Request( + params: .init( + threadID: threadID.rawValue, + input: prompt.appServerInput, + approvalPolicy: options.approvalMode?.approvalPolicy, + approvalsReviewer: options.approvalMode?.approvalsReviewer, + clientUserMessageID: options.clientUserMessageID, + cwd: options.cwd?.path, + effort: options.effort?.rawValue, + model: options.model, + outputSchema: options.outputSchema?.appServerJSONValue, + personality: options.personality?.rawValue, + sandboxPolicy: options.sandbox?.turnSandboxPolicy, + serviceTier: options.serviceTier, + summary: options.summary?.rawValue + ) + ), + reconcileResponse: { response in + try generation.seedResponseSnapshot( + CodexAppServer.turnSnapshots(from: [response.turn])[0] + ) + }, + onWriteAccepted: { + generation.acceptWrite() + pending.acceptWrite() + }, + onResponseRejected: { + generation.rejectResponse() + pending.rejectAcceptedWrite() + }, + onResponseAccepted: generation.acceptResponse, + onPostWriteCancellation: { response in + let turn = await bindTurn( + response, + threadID: threadID, + client: client, + router: router, + store: store, + pending: pending, + state: state + ) + let acknowledgement = try await turn + .interruptAndAwaitTerminalAcknowledgement() + cancellationRecorder?.record(acknowledgement.outcome) + } + ) + } + return await bindTurn( + response, + threadID: threadID, + client: client, + router: router, + store: store, + pending: pending, + state: state + ) + } catch { + await finishPendingTurnOperation(pending, state: state, store: store) + throw error + } +} + +private func bindTurn( + _ response: AppServerAPI.Turn.Start.Response, + threadID: CodexThreadID, + client: AppServerClient, + router: CodexAppServerNotificationRouter, + store: TurnReplayStore, + pending: TurnReplayPendingToken, + state: TurnGenerationHandleState +) async -> CodexTurn { + let snapshot = CodexAppServer.turnSnapshots(from: [response.turn])[0] + let turnID = snapshot.id + await store.bind(pending, to: turnID, initialSnapshot: snapshot) + await router.seedTurn(turnID, threadID: threadID) + return CodexTurn( + id: turnID, + threadID: threadID, + client: client, + router: router, + turnReplayStore: store, + state: state + ) +} + +private func finishPendingTurnOperation( + _ pending: TurnReplayPendingToken, + state: TurnGenerationHandleState, + store: TurnReplayStore +) async { + switch await store.cancelPendingOperation(pending) { + case .removedBeforeWrite: + return + case .retainedAfterWrite: + await store.waitForTermination(retaining: state) + case .notRegistered: + guard await state.snapshot() == .live else { + return + } + await state.closeConnection() + await store.waitForTermination(retaining: state) + } +} + +extension CodexReviewSession { + package func interruptAndAwaitTerminalAcknowledgement( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? = nil + ) async throws -> CodexTurnInterruptionAcknowledgement { + let acknowledgement = try await response.turn.interruptAndAwaitTerminalAcknowledgement( + adoptsRedirectedTurnAsThreadEventOwner: + activeTurnThreadID != sourceThreadID, + willCancelActiveTurn: willCancelActiveTurn + ) + guard activeTurnThreadID == sourceThreadID, + acknowledgement.cancellation.turnID != Optional(turnID) else { + return acknowledgement + } + + // An inline review's redirected child is a TurnReplay generation only. + // The source thread remains on the outer review turn, whose terminal is + // emitted after the child acknowledges interruption. + let outerOutcome = try await response.waitForCancelledResponse(.init( + threadID: activeTurnThreadID, + turnID: turnID + )) + return .init( + cancellation: acknowledgement.cancellation, + outcome: outerOutcome + ) + } +} + +package func withThreadEventGeneration( + _ threadID: CodexThreadID, + router: CodexAppServerNotificationRouter, + generationOperation: ThreadEventGenerationOperation = .standard, + operation: @Sendable (ThreadEventGenerationAttempt) async throws -> Response +) async throws -> Response { + let hub = router.threadEventHub + let checkpoint = try hub.registerCheckpoint(for: threadID, operation: generationOperation) + let generation = ThreadEventGenerationAttempt(hub: hub, checkpoint: checkpoint) + do { + return try await operation(generation) + } catch { + hub.discard(checkpoint) + throw error + } +} + +package struct ThreadEventGenerationAttempt: Sendable { + private let hub: ThreadEventHub + private let checkpoint: ThreadEventGenerationCheckpoint + + fileprivate init(hub: ThreadEventHub, checkpoint: ThreadEventGenerationCheckpoint) { + self.hub = hub + self.checkpoint = checkpoint + } + + package func acceptWrite() { + hub.activate(checkpoint) + } + + package func rejectResponse() { + hub.reject(checkpoint) + } + + package func seedResponseSnapshot(_ snapshot: CodexTurnSnapshot) throws { + try hub.seed(snapshot, at: checkpoint) + } + + package func seedProvisionalResumeSnapshot(_ snapshot: CodexTurnSnapshot) { + hub.seedProvisionalResumeSnapshot(snapshot, at: checkpoint) + } + + package func resolveReviewStartResponse( + eventThreadID: CodexThreadID, + responseSnapshot: CodexTurnSnapshot + ) throws { + try hub.resolveReviewStart( + checkpoint, + eventThreadID: eventThreadID, + responseSnapshot: responseSnapshot + ) + } + + package func acceptResponse() { + hub.commit(checkpoint) + } +} + +extension CodexTurn { + package var events: CodexTurnEventSequence { + .init(turnID: id, store: turnReplayStore, state: state) + } + + package var progress: CodexTurnProgressSequence { + .init(turnID: id, store: turnReplayStore, state: state) + } + + package func result() async throws -> CodexTurnOutcome { + try await CodexResponseCollector.collect(from: events) + } + + package func steer(with prompt: CodexPrompt) async throws { + let _: AppServerAPI.Turn.Steer.Response = try await client.send( + AppServerAPI.Turn.Steer.Request( + params: .init( + threadID: threadID.rawValue, + expectedTurnID: id.rawValue, + input: prompt.appServerInput + ) + )) + } + + package func steer(with prompt: String) async throws { + try await steer(with: CodexPrompt(prompt)) + } + + @discardableResult + package func interrupt() async throws -> CodexTurnCancellation { + if try await state.cachedOutcome() != nil { + return .init(threadID: threadID, turnID: id) + } + return try await interruptCodexTurn(threadID: threadID, turnID: id, client: client) + } + + @discardableResult + package func interrupt( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? + ) async throws -> CodexTurnCancellation { + if try await state.cachedOutcome() != nil { + return .init(threadID: threadID, turnID: id) + } + return try await interruptCodexTurn( + threadID: threadID, + turnID: id, + client: client, + willCancelActiveTurn: willCancelActiveTurn + ) + } + + @discardableResult + package func interruptAndAwaitTerminal( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? = nil + ) async throws -> CodexTurnCancellation { + try await interruptAndAwaitTerminalAcknowledgement( + willCancelActiveTurn: willCancelActiveTurn + ).cancellation + } + + package func interruptAndAwaitTerminalAcknowledgement( + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? = nil + ) async throws -> CodexTurnInterruptionAcknowledgement { + try await interruptAndAwaitTerminalAcknowledgement( + adoptsRedirectedTurnAsThreadEventOwner: true, + willCancelActiveTurn: willCancelActiveTurn + ) + } + + fileprivate func interruptAndAwaitTerminalAcknowledgement( + adoptsRedirectedTurnAsThreadEventOwner: Bool, + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? + ) async throws -> CodexTurnInterruptionAcknowledgement { + if let outcome = try await state.cachedOutcome() { + let cancellation = CodexTurnCancellation(threadID: threadID, turnID: id) + return .init( + cancellation: cancellation, + outcome: outcome + ) + } + let connectionLease = try await state.connectionLeaseForSiblingGeneration() + let prepared = try await interruptCodexTurnPreparingTarget( + threadID: threadID, + turnID: id, + client: client, + router: router, + originalState: state, + store: turnReplayStore, + connectionLease: connectionLease, + adoptsRedirectedTurnAsThreadEventOwner: adoptsRedirectedTurnAsThreadEventOwner, + willCancelActiveTurn: willCancelActiveTurn + ) + let outcome = try await CodexResponseStream(turn: self).waitForCancelledResponse( + prepared.cancellation, + preparedState: prepared.state + ) + return .init(cancellation: prepared.cancellation, outcome: outcome) + } +} + +package struct CodexTurnInterruptionAcknowledgement: Sendable { + package var cancellation: CodexTurnCancellation + package var outcome: CodexTurnOutcome +} + +private struct PreparedTurnInterruption: Sendable { + var cancellation: CodexTurnCancellation + var state: TurnGenerationHandleState +} + +private func interruptCodexTurnPreparingTarget( + threadID: CodexThreadID, + turnID: CodexTurnID, + client: AppServerClient, + router: CodexAppServerNotificationRouter, + originalState: TurnGenerationHandleState, + store: TurnReplayStore, + connectionLease: AppServerConnectionLease, + adoptsRedirectedTurnAsThreadEventOwner: Bool, + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? +) async throws -> PreparedTurnInterruption { + var resolver = InterruptRaceResolver(expectedTurnID: turnID) + while true { + do { + try await sendInterrupt(threadID: threadID, turnID: turnID, client: client) + return .init( + cancellation: .init(threadID: threadID, turnID: turnID), + state: originalState + ) + } catch { + switch resolver.decision(for: error) { + case .retry(let delay): + try await client.sleepForInterruptRace(delay) + continue + case .fail: + throw error + case .redirect(let activeTurn): + let cancellation = CodexTurnCancellation(threadID: threadID, turnID: activeTurn) + let state = await store.restoreGeneration( + turnID: activeTurn, + initialSnapshot: .init( + id: activeTurn, + state: .inProgress, + itemsLoadState: .notLoaded + ), + connectionLease: connectionLease + ) + if adoptsRedirectedTurnAsThreadEventOwner { + await router.adoptThreadEventGeneration(threadID, including: activeTurn) + } + if let willCancelActiveTurn { + await willCancelActiveTurn(cancellation) + } + try await sendInterrupt(threadID: threadID, turnID: activeTurn, client: client) + return .init(cancellation: cancellation, state: state) + } + } + } +} + +@discardableResult +package func interruptCodexTurn( + threadID: CodexThreadID, + turnID: CodexTurnID?, + client: AppServerClient, + willCancelActiveTurn: (@Sendable (CodexTurnCancellation) async -> Void)? = nil +) async throws -> CodexTurnCancellation { + var resolver = InterruptRaceResolver(expectedTurnID: turnID) + while true { + do { + try await sendInterrupt(threadID: threadID, turnID: turnID, client: client) + return .init(threadID: threadID, turnID: turnID) + } catch { + switch resolver.decision(for: error) { + case .retry(let delay): + try await client.sleepForInterruptRace(delay) + continue + case .fail: + throw error + case .redirect(let activeTurn): + let cancellation = CodexTurnCancellation(threadID: threadID, turnID: activeTurn) + if let willCancelActiveTurn { + await willCancelActiveTurn(cancellation) + } + try await sendInterrupt(threadID: threadID, turnID: activeTurn, client: client) + return cancellation + } + } + } +} + +private func sendInterrupt( + threadID: CodexThreadID, + turnID: CodexTurnID?, + client: AppServerClient +) async throws { + let _: EmptyResponse = try await client.send( + AppServerAPI.Turn.Interrupt.Request( + params: .init(threadID: threadID.rawValue, turnID: turnID?.rawValue ?? "") + )) +} + +extension CodexPrompt { + package var appServerInput: [AppServerAPI.UserInput] { + parts.map { part in + switch part { + case .text(let text): + .text(text) + case .imageURL(let url): + .image(url: url.absoluteString) + case .localImage(let url): + .localImage(path: url.path) + case .skill(let name, let path): + .skill(name: name, path: path.path) + case .mention(let name, let path): + .mention(name: name, path: path.appServerMentionPath) + } + } + } +} + +private extension URL { + var appServerMentionPath: String { + isFileURL ? path : absoluteString + } +} diff --git a/Sources/CodexAppServerKit/CodexTurnSequences.swift b/Sources/CodexAppServerKit/CodexTurnSequences.swift new file mode 100644 index 00000000..f858b2bc --- /dev/null +++ b/Sources/CodexAppServerKit/CodexTurnSequences.swift @@ -0,0 +1,944 @@ +import Foundation + +package struct CodexThreadMessageSequence: AsyncSequence, Sendable { + package typealias Element = CodexMessage + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexThreadEventSequence.Iterator + private var pendingMessages: [CodexMessage] = [] + private var pendingMessageIndex = 0 + private var emittedMessagesByID: [String: CodexMessage] = [:] + private var currentTurnID: CodexTurnID? + + fileprivate init(events: CodexThreadEventSequence.Iterator) { + self.events = events + } + + package mutating func next() async throws -> CodexMessage? { + if let pending = nextPendingMessage() { + return pending + } + while let event = try await events.next() { + switch event { + case .message(let message, let turnID): + beginGenerationIfNeeded(turnID) + emittedMessagesByID[message.id] = message + return message + case .itemCompleted(let item, let turnID): + beginGenerationIfNeeded(turnID) + if let message = item.message { + emittedMessagesByID[message.id] = message + return message + } + case .snapshot(let snapshot): + beginGenerationIfNeeded(snapshot.id) + pendingMessages = snapshot.items.compactMap(\.message).filter { + emittedMessagesByID[$0.id] != $0 + } + for message in pendingMessages { + emittedMessagesByID[message.id] = message + } + pendingMessageIndex = 0 + if let pending = nextPendingMessage() { + return pending + } + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .messageDelta(_, let turnID), + .reasoningSummaryPartAdded(_, let turnID), + .reasoningDelta(_, let turnID), + .tokenUsageUpdated(_, let turnID): + beginGenerationIfNeeded(turnID) + case .diagnostic(_, let turnID): + beginGenerationIfNeeded(turnID) + case .turnStarted(let turnID): + beginGenerationIfNeeded(turnID) + case .terminal(let outcome): + beginGenerationIfNeeded(outcome.response.turnID) + case .unknown(let raw): + beginGenerationIfNeeded(raw.turnID) + case .statusChanged, .closed: + break + } + } + return nil + } + + private mutating func nextPendingMessage() -> CodexMessage? { + guard pendingMessageIndex < pendingMessages.count else { + pendingMessages.removeAll(keepingCapacity: false) + pendingMessageIndex = 0 + return nil + } + defer { pendingMessageIndex += 1 } + return pendingMessages[pendingMessageIndex] + } + + private mutating func beginGenerationIfNeeded(_ turnID: CodexTurnID?) { + guard let turnID, currentTurnID != turnID else { + return + } + currentTurnID = turnID + pendingMessages.removeAll(keepingCapacity: false) + pendingMessageIndex = 0 + emittedMessagesByID.removeAll(keepingCapacity: false) + } + } +} + +package struct CodexThreadTranscriptSequence: AsyncSequence, Sendable { + package typealias Element = CodexTranscript + + private let events: CodexThreadEventSequence + + package init(events: CodexThreadEventSequence) { + self.events = events + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexThreadEventSequence.Iterator + private var accumulator = CodexTranscriptAccumulator() + + fileprivate init(events: CodexThreadEventSequence.Iterator) { + self.events = events + } + + package mutating func next() async throws -> CodexTranscript? { + while let event = try await events.next() { + if accumulator.apply(event) { + return accumulator.transcript + } + } + return nil + } + } +} + +package struct CodexThreadLogSequence: AsyncSequence, Sendable { + package typealias Element = CodexThreadLogEntry + + private let events: CodexThreadEventSequence + private let terminalTurnID: CodexTurnID? + + package init(events: CodexThreadEventSequence, terminalTurnID: CodexTurnID? = nil) { + self.events = events + self.terminalTurnID = terminalTurnID + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator(), terminalTurnID: terminalTurnID) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexThreadEventSequence.Iterator + private let terminalTurnID: CodexTurnID? + private var logEntryIndex = 0 + private var finished = false + private var pendingSnapshotItems: [CodexThreadItem] = [] + private var pendingSnapshotIndex = 0 + private var pendingSnapshotTurnID: CodexTurnID? + private var emittedItemsByID: [String: CodexThreadItem] = [:] + private var currentTurnID: CodexTurnID? + + fileprivate init( + events: CodexThreadEventSequence.Iterator, + terminalTurnID: CodexTurnID? + ) { + self.events = events + self.terminalTurnID = terminalTurnID + } + + package mutating func next() async throws -> CodexThreadLogEntry? { + guard finished == false else { + return nil + } + if let pending = nextPendingSnapshotEntry() { + return pending + } + while let event = try await events.next() { + guard reviewEventMatches(event, terminalTurnID: terminalTurnID) else { + continue + } + switch event { + case .itemStarted(let item, let turnID): + beginGenerationIfNeeded(turnID) + emittedItemsByID[item.id] = item + return .itemStarted(item, turnID: turnID) + case .itemUpdated(let item, let turnID): + beginGenerationIfNeeded(turnID) + emittedItemsByID[item.id] = item + return .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item, let turnID): + beginGenerationIfNeeded(turnID) + emittedItemsByID[item.id] = item + return .itemCompleted(item, turnID: turnID) + case .message(let message, let turnID): + beginGenerationIfNeeded(turnID) + let item = CodexThreadItem( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + emittedItemsByID[item.id] = item + return .itemCompleted(item, turnID: turnID) + case .messageDelta(let delta, let turnID): + beginGenerationIfNeeded(turnID) + return .messageDelta(delta, turnID: turnID, id: nextDeltaLogEntryID(for: delta)) + case .reasoningSummaryPartAdded(let part, let turnID): + beginGenerationIfNeeded(turnID) + return .reasoningPartStarted(part, turnID: turnID) + case .reasoningDelta(let delta, let turnID): + beginGenerationIfNeeded(turnID) + return .reasoningDelta(delta, turnID: turnID) + case .diagnostic(let diagnostic, let turnID): + beginGenerationIfNeeded(turnID) + return .diagnostic( + diagnostic, + turnID: turnID, + id: nextDiagnosticLogEntryID(turnID: turnID) + ) + case .snapshot(let snapshot): + beginGenerationIfNeeded(snapshot.id) + pendingSnapshotItems = snapshot.items.filter { + emittedItemsByID[$0.id] != $0 + } + for item in pendingSnapshotItems { + emittedItemsByID[item.id] = item + } + pendingSnapshotIndex = 0 + pendingSnapshotTurnID = snapshot.id + if let pending = nextPendingSnapshotEntry() { + return pending + } + case .terminal(let outcome): + beginGenerationIfNeeded(outcome.response.turnID) + guard terminalTurnID != nil else { + continue + } + finished = true + return nil + case .closed: + finished = true + return nil + case .turnStarted(let turnID): + beginGenerationIfNeeded(turnID) + case .tokenUsageUpdated(_, let turnID): + beginGenerationIfNeeded(turnID) + case .unknown(let raw): + beginGenerationIfNeeded(raw.turnID) + case .statusChanged: + break + } + } + finished = true + return nil + } + + private mutating func nextPendingSnapshotEntry() -> CodexThreadLogEntry? { + guard pendingSnapshotIndex < pendingSnapshotItems.count else { + pendingSnapshotItems.removeAll(keepingCapacity: false) + pendingSnapshotIndex = 0 + pendingSnapshotTurnID = nil + return nil + } + defer { pendingSnapshotIndex += 1 } + return .itemCompleted( + pendingSnapshotItems[pendingSnapshotIndex], + turnID: pendingSnapshotTurnID + ) + } + + private mutating func beginGenerationIfNeeded(_ turnID: CodexTurnID?) { + guard let turnID, currentTurnID != turnID else { + return + } + currentTurnID = turnID + pendingSnapshotItems.removeAll(keepingCapacity: false) + pendingSnapshotIndex = 0 + pendingSnapshotTurnID = nil + emittedItemsByID.removeAll(keepingCapacity: false) + } + + private mutating func nextDeltaLogEntryID(for delta: CodexMessageDelta) -> String { + defer { + logEntryIndex += 1 + } + return "\(delta.itemID):\(logEntryIndex)" + } + + private mutating func nextDiagnosticLogEntryID(turnID: CodexTurnID) -> String { + defer { + logEntryIndex += 1 + } + return "\(turnID.rawValue):diagnostic:\(logEntryIndex)" + } + } +} + +/// Projection over a thread event stream for a `CodexReviewSession`. +package struct CodexReviewEventSequence: AsyncSequence, Sendable { + package typealias Element = CodexReviewEvent + + private let events: CodexTurnEventSequence + private let turnID: CodexTurnID + + package init(events: CodexTurnEventSequence, turnID: CodexTurnID) { + self.events = events + self.turnID = turnID + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator(), turnID: turnID) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexTurnEventSequence.Iterator + private let turnID: CodexTurnID + private var finished = false + + fileprivate init( + events: CodexTurnEventSequence.Iterator, + turnID: CodexTurnID + ) { + self.events = events + self.turnID = turnID + } + + package mutating func next() async throws -> CodexReviewEvent? { + guard finished == false else { + return nil + } + guard let event = try await events.next() else { + finished = true + return nil + } + switch event { + case .terminal(let outcome): + finished = true + return .terminal(outcome) + case .started, .snapshot, .itemStarted, .itemUpdated, .itemCompleted, .message, + .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .unknown: + return CodexReviewEvent(event, turnID: turnID) + } + } + } +} + +/// Incremental review progress projected from the thread event stream. +package struct CodexReviewProgressSequence: AsyncSequence, Sendable { + package typealias Element = CodexReviewProgress + + private let turnID: CodexTurnID + private let store: TurnReplayStore + private let state: TurnGenerationHandleState + + package init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.turnID = turnID + self.store = store + self.state = state + } + + package func makeAsyncIterator() -> Iterator { + Iterator(turnID: turnID, store: store, state: state) + } + + package struct Iterator: AsyncIteratorProtocol { + private let turnID: CodexTurnID + private let store: TurnReplayStore + private let state: TurnGenerationHandleState + private var events: TurnReplayProgressEvents.Iterator? + + fileprivate init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.turnID = turnID + self.store = store + self.state = state + } + + package mutating func next() async throws -> CodexReviewProgress? { + if events == nil { + events = try await store.progressEvents(for: turnID, state: state) + .makeAsyncIterator() + } + guard var iterator = events else { + preconditionFailure("A replay progress iterator must be installed before use.") + } + let value = try await iterator.next() + events = iterator + return value + } + } +} + +private func reviewEventMatches( + _ event: CodexThreadEvent, + terminalTurnID: CodexTurnID? +) -> Bool { + guard let terminalTurnID else { + return true + } + switch event { + case .turnStarted(let turnID): + return turnID == terminalTurnID + case .snapshot(let snapshot): + return snapshot.id == terminalTurnID + case .terminal(let outcome): + return outcome.response.turnID == terminalTurnID + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .message(_, let turnID), .messageDelta(_, let turnID), + .reasoningSummaryPartAdded(_, let turnID), .reasoningDelta(_, let turnID), + .tokenUsageUpdated(_, let turnID): + return turnID == terminalTurnID + case .diagnostic(_, let turnID): + return turnID == terminalTurnID + case .unknown(let raw): + return raw.turnID.map { $0 == terminalTurnID } ?? true + case .statusChanged: + return true + case .closed: + return true + } +} + +package struct CodexTurnEventSequence: AsyncSequence, Sendable { + package typealias Element = CodexTurnEvent + + private let turnID: CodexTurnID + private let store: TurnReplayStore + private let state: TurnGenerationHandleState + + package init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.turnID = turnID + self.store = store + self.state = state + } + + package func makeAsyncIterator() -> Iterator { + Iterator(turnID: turnID, store: store, state: state) + } + + package struct Iterator: AsyncIteratorProtocol { + private let turnID: CodexTurnID + private let store: TurnReplayStore + private let state: TurnGenerationHandleState + private var events: TurnReplayEvents.Iterator? + + fileprivate init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.turnID = turnID + self.store = store + self.state = state + } + + package mutating func next() async throws -> CodexTurnEvent? { + if events == nil { + events = try await store.events(for: turnID, state: state) + .makeAsyncIterator() + } + guard var iterator = events else { + preconditionFailure("A turn replay iterator must be installed before use.") + } + let value = try await iterator.next() + events = iterator + return value + } + } +} + +package struct CodexTurnProgressSequence: AsyncSequence, Sendable { + package typealias Element = CodexTurnProgress + + private let turnID: CodexTurnID + private let store: TurnReplayStore + private let state: TurnGenerationHandleState + + package init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.turnID = turnID + self.store = store + self.state = state + } + + package func makeAsyncIterator() -> Iterator { + Iterator(turnID: turnID, store: store, state: state) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexReviewProgressSequence.Iterator + + fileprivate init( + turnID: CodexTurnID, + store: TurnReplayStore, + state: TurnGenerationHandleState + ) { + self.events = CodexReviewProgressSequence( + turnID: turnID, + store: store, + state: state + ).makeAsyncIterator() + } + + package mutating func next() async throws -> CodexTurnProgress? { + switch try await events.next() { + case .running(let transcript, let usage): + .running(transcript: transcript, usage: usage) + case .terminal(let outcome): + .terminal(outcome) + case nil: + nil + } + } + } +} + +package struct CodexTurnMessageSequence: AsyncSequence, Sendable { + package typealias Element = CodexMessage + private let events: CodexTurnEventSequence + + package init(events: CodexTurnEventSequence) { + self.events = events + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexTurnEventSequence.Iterator + private var pendingMessages: [CodexMessage] = [] + private var pendingMessageIndex = 0 + + fileprivate init(events: CodexTurnEventSequence.Iterator) { + self.events = events + } + + package mutating func next() async throws -> CodexMessage? { + if let pending = nextPendingMessage() { + return pending + } + while let event = try await events.next() { + switch event { + case .message(let message): + return message + case .itemCompleted(let item): + if let message = item.message { + return message + } + case .snapshot(let snapshot): + pendingMessages = snapshot.items.compactMap(\.message) + pendingMessageIndex = 0 + if let pending = nextPendingMessage() { + return pending + } + case .started, .terminal, .itemStarted, .itemUpdated, + .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .unknown: + continue + } + } + return nil + } + + private mutating func nextPendingMessage() -> CodexMessage? { + guard pendingMessageIndex < pendingMessages.count else { + pendingMessages.removeAll(keepingCapacity: false) + pendingMessageIndex = 0 + return nil + } + defer { pendingMessageIndex += 1 } + return pendingMessages[pendingMessageIndex] + } + } +} + +package struct CodexTurnTranscriptSequence: AsyncSequence, Sendable { + package typealias Element = CodexTranscript + private let events: CodexTurnEventSequence + + package init(events: CodexTurnEventSequence) { + self.events = events + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator()) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexTurnEventSequence.Iterator + private var accumulator = CodexTranscriptAccumulator() + + fileprivate init(events: CodexTurnEventSequence.Iterator) { + self.events = events + } + + package mutating func next() async throws -> CodexTranscript? { + while let event = try await events.next() { + if accumulator.apply(event) { + return accumulator.transcript + } + } + return nil + } + } +} + +package struct CodexTurnLogSequence: AsyncSequence, Sendable { + package typealias Element = CodexThreadLogEntry + private let events: CodexTurnEventSequence + private let turnID: CodexTurnID + + package init(events: CodexTurnEventSequence, turnID: CodexTurnID) { + self.events = events + self.turnID = turnID + } + + package func makeAsyncIterator() -> Iterator { + Iterator(events: events.makeAsyncIterator(), turnID: turnID) + } + + package struct Iterator: AsyncIteratorProtocol { + private var events: CodexTurnEventSequence.Iterator + private let turnID: CodexTurnID + private var logEntryIndex = 0 + private var pendingSnapshotItems: [CodexThreadItem] = [] + private var pendingSnapshotIndex = 0 + + fileprivate init(events: CodexTurnEventSequence.Iterator, turnID: CodexTurnID) { + self.events = events + self.turnID = turnID + } + + package mutating func next() async throws -> CodexThreadLogEntry? { + if let pending = nextPendingSnapshotEntry() { + return pending + } + while let event = try await events.next() { + switch event { + case .itemStarted(let item): + return .itemStarted(item, turnID: turnID) + case .itemUpdated(let item): + return .itemUpdated(item, turnID: turnID) + case .itemCompleted(let item): + return .itemCompleted(item, turnID: turnID) + case .message(let message): + return .itemCompleted( + .init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ), + turnID: turnID + ) + case .messageDelta(let delta): + defer { logEntryIndex += 1 } + return .messageDelta( + delta, + turnID: turnID, + id: "\(delta.itemID):\(logEntryIndex)" + ) + case .reasoningSummaryPartAdded(let part): + return .reasoningPartStarted(part, turnID: turnID) + case .reasoningDelta(let delta): + return .reasoningDelta(delta, turnID: turnID) + case .diagnostic(let diagnostic): + defer { logEntryIndex += 1 } + return .diagnostic( + diagnostic, + turnID: turnID, + id: "\(turnID.rawValue):diagnostic:\(logEntryIndex)" + ) + case .terminal: + return nil + case .snapshot(let snapshot): + pendingSnapshotItems = snapshot.items + pendingSnapshotIndex = 0 + if let pending = nextPendingSnapshotEntry() { + return pending + } + case .started, .tokenUsageUpdated, .unknown: + continue + } + } + return nil + } + + private mutating func nextPendingSnapshotEntry() -> CodexThreadLogEntry? { + guard pendingSnapshotIndex < pendingSnapshotItems.count else { + pendingSnapshotItems.removeAll(keepingCapacity: false) + pendingSnapshotIndex = 0 + return nil + } + defer { pendingSnapshotIndex += 1 } + return .itemCompleted( + pendingSnapshotItems[pendingSnapshotIndex], + turnID: turnID + ) + } + } +} + +package struct CodexResponseCollector { + static func collect(from events: CodexTurnEventSequence) async throws -> CodexTurnOutcome { + var accumulator = CodexResponseAccumulator() + for try await event in events { + switch event { + case .started, .snapshot, .diagnostic, .unknown: + continue + case .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: + _ = accumulator.apply(event) + case .tokenUsageUpdated: + _ = accumulator.apply(event) + case .terminal(let outcome): + return accumulator.finalized(outcome) + } + } + try Task.checkCancellation() + throw CodexAppServerError.connectionTerminated(.transportFailure(.closed)) + } +} + +private struct CodexResponseAccumulator { + private var transcriptAccumulator = CodexTranscriptAccumulator() + private(set) var usage: CodexTokenUsage? + + var transcript: CodexTranscript { + transcriptAccumulator.transcript + } + + mutating func apply(_ event: CodexTurnEvent) -> Bool { + switch event { + case .tokenUsageUpdated(let newUsage): + usage = newUsage + return true + case .started, .snapshot, .diagnostic, .terminal, .unknown: + return false + case .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: + return transcriptAccumulator.apply(event) + } + } + + mutating func apply(_ event: CodexThreadEvent) -> Bool { + switch event { + case .tokenUsageUpdated(let newUsage, _): + usage = newUsage + return true + case .turnStarted, .snapshot, .diagnostic, .terminal, .statusChanged, .closed, .unknown: + return false + case .itemStarted, .itemUpdated, .itemCompleted, .message, .messageDelta, + .reasoningSummaryPartAdded, .reasoningDelta: + return transcriptAccumulator.apply(event) + } + } + + func finalized(_ response: CodexResponse) -> CodexResponse { + var response = response + let finalizedTranscript = finalizedTranscript( + for: response.transcript, + itemsLoadState: response.transcriptItemsLoadState + ) + response.transcript = finalizedTranscript + if response.usage == nil { + response.usage = usage + } + return response + } + + func finalized(_ outcome: CodexTurnOutcome) -> CodexTurnOutcome { + switch outcome { + case .completed(let response): + .completed(finalized(response)) + case .interrupted(let response): + .interrupted(finalized(response)) + case .failed(let failedTurn): + .failed(.init(response: finalized(failedTurn.response), error: failedTurn.error)) + case .invalidTerminalStatus(let rawStatus, let error, let response): + .invalidTerminalStatus( + rawStatus: rawStatus, + error: error, + response: finalized(response) + ) + } + } + + private func finalizedTranscript( + for terminalTranscript: CodexTranscript, + itemsLoadState: CodexTurnItemsLoadState + ) -> CodexTranscript { + guard itemsLoadState != .full else { + return terminalTranscript + } + let liveTranscript = transcript + guard terminalTranscript.items.isEmpty == false else { + return liveTranscript + } + guard terminalTranscript.reviewOutputText == nil else { + return terminalTranscript + } + + var mergedItems = terminalTranscript.items + var didMerge = false + for liveItem in liveTranscript.items where liveItem.kind == .exitedReviewMode { + guard liveItem.text?.isEmpty == false else { + continue + } + if let index = mergedItems.firstIndex(where: { $0.id == liveItem.id && $0.kind == liveItem.kind }) { + guard mergedItems[index].text?.isEmpty != false else { + continue + } + mergedItems[index] = liveItem + } else { + mergedItems.append(liveItem) + } + didMerge = true + } + guard didMerge else { + return terminalTranscript + } + return CodexTranscript(items: mergedItems) + } +} + +private struct CodexTranscriptAccumulator { + private var items: [CodexThreadItem] = [] + private var itemIndexesByID: [String: Int] = [:] + + var transcript: CodexTranscript { + .init(items: items) + } + + mutating func apply(_ event: CodexTurnEvent) -> Bool { + switch event { + case .snapshot(let snapshot): + let previousItems = items + replace(with: snapshot.items) + return items != previousItems + case .itemStarted(let item), .itemUpdated(let item), .itemCompleted(let item): + upsert(item) + return true + case .message(let message): + upsert( + .init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + ) + return true + case .messageDelta(let delta): + upsert(Self.currentItem(from: delta)) + return true + case .reasoningSummaryPartAdded(let part): + upsert(Self.currentItem(from: part)) + return true + case .reasoningDelta(let delta): + upsert(Self.currentItem(from: delta)) + return true + case .started, .diagnostic, .tokenUsageUpdated, .terminal, .unknown: + return false + } + } + + private mutating func replace(with snapshotItems: [CodexThreadItem]) { + items.removeAll(keepingCapacity: true) + itemIndexesByID.removeAll(keepingCapacity: true) + for item in snapshotItems { + upsert(item) + } + } + + mutating func apply(_ event: CodexThreadEvent) -> Bool { + switch event { + case .snapshot(let snapshot): + let previousItems = items + replace(with: snapshot.items) + return items != previousItems + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + upsert(item) + return true + case .message(let message, _): + upsert( + .init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + ) + return true + case .messageDelta(let delta, _): + upsert(Self.currentItem(from: delta)) + return true + case .reasoningSummaryPartAdded(let part, _): + upsert(Self.currentItem(from: part)) + return true + case .reasoningDelta(let delta, _): + upsert(Self.currentItem(from: delta)) + return true + case .turnStarted, .diagnostic, .terminal, .tokenUsageUpdated, .statusChanged, + .closed, .unknown: + return false + } + } + + private mutating func upsert(_ item: CodexThreadItem) { + if let index = itemIndexesByID[item.id] { + items[index] = item + } else { + itemIndexesByID[item.id] = items.count + items.append(item) + } + } + + private static func currentItem(from delta: CodexMessageDelta) -> CodexThreadItem { + guard let currentItem = delta.currentItem else { + preconditionFailure("CodexMessageDelta must be emitted through CodexItemReducer.") + } + return currentItem + } + + private static func currentItem(from part: CodexReasoningPart) -> CodexThreadItem { + guard let currentItem = part.currentItem else { + preconditionFailure("CodexReasoningPart must be emitted through CodexItemReducer.") + } + return currentItem + } + + private static func currentItem(from delta: CodexReasoningDelta) -> CodexThreadItem { + guard let currentItem = delta.currentItem else { + preconditionFailure("CodexReasoningDelta must be emitted through CodexItemReducer.") + } + return currentItem + } +} diff --git a/Sources/CodexAppServerKit/CodexTurnSnapshotReducer.swift b/Sources/CodexAppServerKit/CodexTurnSnapshotReducer.swift new file mode 100644 index 00000000..e83258e7 --- /dev/null +++ b/Sources/CodexAppServerKit/CodexTurnSnapshotReducer.swift @@ -0,0 +1,291 @@ +import Foundation + +struct CodexTurnItemIdentity: Hashable, Sendable { + var id: String + var kind: CodexThreadItem.Kind + + init(_ item: CodexThreadItem) { + id = item.id + kind = item.kind + } +} + +struct CodexTurnSnapshotReducer: Equatable, Sendable { + private enum ItemEvidence: Equatable, Sendable { + case partialSnapshot + case completeSnapshot + case observed + } + + private(set) var snapshot: CodexTurnSnapshot + private var evidenceByIdentity: [CodexTurnItemIdentity: ItemEvidence] + private var hasSnapshotEvidence: Bool + + init(turnID: CodexTurnID) { + snapshot = .init( + id: turnID, + state: .inProgress, + itemsLoadState: .notLoaded + ) + evidenceByIdentity = [:] + hasSnapshotEvidence = false + } + + init(snapshot: CodexTurnSnapshot) { + self.snapshot = snapshot + evidenceByIdentity = Self.snapshotEvidence(for: snapshot) + hasSnapshotEvidence = true + } + + mutating func markStarted() { + guard hasSnapshotEvidence == false else { + return + } + snapshot.itemsLoadState = .full + } + + mutating func replace(with newSnapshot: CodexTurnSnapshot) { + replace(with: newSnapshot, hasSnapshotEvidence: true) + } + + mutating func replaceBindingSnapshot(with newSnapshot: CodexTurnSnapshot) { + replace( + with: newSnapshot, + hasSnapshotEvidence: newSnapshot.itemsLoadState != .notLoaded + || newSnapshot.items.isEmpty == false + ) + } + + private mutating func replace( + with newSnapshot: CodexTurnSnapshot, + hasSnapshotEvidence: Bool + ) { + precondition(newSnapshot.id == snapshot.id) + snapshot = newSnapshot + evidenceByIdentity = Self.snapshotEvidence(for: newSnapshot) + self.hasSnapshotEvidence = hasSnapshotEvidence + } + + mutating func merge(_ newSnapshot: CodexTurnSnapshot) { + precondition(newSnapshot.id == snapshot.id) + hasSnapshotEvidence = true + if snapshot.itemsLoadState == .full, newSnapshot.itemsLoadState != .full { + mergePartialSnapshotIntoCompleteState(newSnapshot) + return + } + var items = newSnapshot.items + var indexes = Self.indexes(for: items) + var mergedEvidence = Self.snapshotEvidence(for: newSnapshot) + + for item in snapshot.items { + let identity = CodexTurnItemIdentity(item) + let evidence = evidenceByIdentity[identity] + ?? Self.snapshotEvidence(for: snapshot.itemsLoadState) + if let index = indexes[identity] { + if evidence == .observed + || (evidence == .completeSnapshot && newSnapshot.itemsLoadState != .full) + { + items[index] = item + mergedEvidence[identity] = evidence + } + } else if newSnapshot.itemsLoadState != .full || evidence == .observed { + indexes[identity] = items.count + items.append(item) + mergedEvidence[identity] = evidence + } + } + + snapshot = .init( + id: newSnapshot.id, + state: newSnapshot.state, + itemsLoadState: Self.moreComplete( + snapshot.itemsLoadState, + newSnapshot.itemsLoadState + ), + items: items, + startedAt: newSnapshot.startedAt ?? snapshot.startedAt, + completedAt: newSnapshot.completedAt ?? snapshot.completedAt, + duration: newSnapshot.duration ?? snapshot.duration + ) + evidenceByIdentity = mergedEvidence + } + + private mutating func mergePartialSnapshotIntoCompleteState( + _ newSnapshot: CodexTurnSnapshot + ) { + var indexes = Self.indexes(for: snapshot.items) + for item in newSnapshot.items { + let identity = CodexTurnItemIdentity(item) + guard indexes[identity] == nil else { + continue + } + indexes[identity] = snapshot.items.count + snapshot.items.append(item) + evidenceByIdentity[identity] = .partialSnapshot + } + snapshot = .init( + id: newSnapshot.id, + state: newSnapshot.state, + itemsLoadState: .full, + items: snapshot.items, + startedAt: newSnapshot.startedAt ?? snapshot.startedAt, + completedAt: newSnapshot.completedAt ?? snapshot.completedAt, + duration: newSnapshot.duration ?? snapshot.duration + ) + } + + mutating func observe(_ item: CodexThreadItem) { + let identity = CodexTurnItemIdentity(item) + if let index = snapshot.items.firstIndex(where: { + CodexTurnItemIdentity($0) == identity + }) { + snapshot.items[index] = item + } else { + snapshot.items.append(item) + } + evidenceByIdentity[identity] = .observed + } + + mutating func finish(_ outcome: CodexTurnOutcome) -> CompactTurnSnapshot { + precondition(outcome.response.turnID == snapshot.id) + var response = outcome.response + let terminalItems = response.transcript.items + let terminalLoadState = response.transcriptItemsLoadState + + if terminalLoadState == .full { + snapshot.items = terminalItems + evidenceByIdentity = Self.snapshotEvidence( + for: terminalItems, + loadState: .full + ) + } else { + var indexes = Self.indexes(for: snapshot.items) + for terminalItem in terminalItems { + let identity = CodexTurnItemIdentity(terminalItem) + if let index = indexes[identity] { + switch evidenceByIdentity[identity] { + case .completeSnapshot, .observed: + break + case .partialSnapshot, nil: + snapshot.items[index] = terminalItem + evidenceByIdentity[identity] = .partialSnapshot + } + } else { + indexes[identity] = snapshot.items.count + snapshot.items.append(terminalItem) + evidenceByIdentity[identity] = .partialSnapshot + } + } + } + + let finalLoadState = Self.moreComplete( + snapshot.itemsLoadState, + terminalLoadState + ) + response.transcript = .init(items: snapshot.items) + response.transcriptItemsLoadState = finalLoadState + let finalOutcome = outcome.replacingResponse(response) + snapshot = .init( + id: response.turnID, + state: .init(outcome: finalOutcome), + itemsLoadState: finalLoadState, + items: response.transcript.items, + startedAt: response.startedAt ?? snapshot.startedAt, + completedAt: response.completedAt ?? snapshot.completedAt, + duration: response.duration ?? snapshot.duration + ) + if finalLoadState == .full { + evidenceByIdentity = Self.snapshotEvidence(for: snapshot) + } + return .init(snapshot: snapshot, outcome: finalOutcome) + } + + private static func snapshotEvidence( + for snapshot: CodexTurnSnapshot + ) -> [CodexTurnItemIdentity: ItemEvidence] { + snapshotEvidence(for: snapshot.items, loadState: snapshot.itemsLoadState) + } + + private static func snapshotEvidence( + for items: [CodexThreadItem], + loadState: CodexTurnItemsLoadState + ) -> [CodexTurnItemIdentity: ItemEvidence] { + let evidence = snapshotEvidence(for: loadState) + var result: [CodexTurnItemIdentity: ItemEvidence] = [:] + for item in items { + let identity = CodexTurnItemIdentity(item) + precondition( + result.updateValue(evidence, forKey: identity) == nil, + "A turn snapshot cannot contain duplicate item identities." + ) + } + return result + } + + private static func snapshotEvidence( + for loadState: CodexTurnItemsLoadState + ) -> ItemEvidence { + loadState == .full ? .completeSnapshot : .partialSnapshot + } + + private static func indexes( + for items: [CodexThreadItem] + ) -> [CodexTurnItemIdentity: Int] { + var result: [CodexTurnItemIdentity: Int] = [:] + for index in items.indices { + let identity = CodexTurnItemIdentity(items[index]) + precondition( + result.updateValue(index, forKey: identity) == nil, + "A turn snapshot cannot contain duplicate item identities." + ) + } + return result + } + + private static func moreComplete( + _ lhs: CodexTurnItemsLoadState, + _ rhs: CodexTurnItemsLoadState + ) -> CodexTurnItemsLoadState { + if lhs == .full || rhs == .full { + return .full + } + if lhs == .summary || rhs == .summary { + return .summary + } + return .notLoaded + } +} + +private extension CodexTurnSnapshot.State { + init(outcome: CodexTurnOutcome) { + switch outcome { + case .completed: + self = .completed + case .interrupted: + self = .interrupted + case .failed(let failed): + self = .failed(failed.error) + case .invalidTerminalStatus(let rawStatus, let error, _): + self = .unknown(rawValue: rawStatus, error: error) + } + } +} + +private extension CodexTurnOutcome { + func replacingResponse(_ response: CodexResponse) -> Self { + switch self { + case .completed: + .completed(response) + case .interrupted: + .interrupted(response) + case .failed(let failed): + .failed(.init(response: response, error: failed.error)) + case .invalidTerminalStatus(let rawStatus, let error, _): + .invalidTerminalStatus( + rawStatus: rawStatus, + error: error, + response: response + ) + } + } +} diff --git a/Sources/CodexAppServerKit/ConnectionEventHub.swift b/Sources/CodexAppServerKit/ConnectionEventHub.swift new file mode 100644 index 00000000..1fa0e88e --- /dev/null +++ b/Sources/CodexAppServerKit/ConnectionEventHub.swift @@ -0,0 +1,569 @@ +import Foundation +import Synchronization + +/// A connection-scoped diagnostic or terminal event emitted by Codex app-server. +public enum CodexConnectionEvent: Equatable, Sendable { + case warning(CodexDiagnostic) + case retrying(CodexRetryDiagnostic) + case deprecation(CodexDeprecationNotice) + case unknown(CodexRawNotification) + case terminated(CodexConnectionTermination) +} + +/// A user-visible warning associated with the app-server connection. +public struct CodexDiagnostic: Equatable, Sendable { + public let message: String + public let method: String? + public let details: String? + + package init(message: String, method: String? = nil, details: String? = nil) { + self.message = message + self.method = method + self.details = details + } +} + +/// A scheduled retry after an app-server overload response. +public struct CodexRetryDiagnostic: Equatable, Sendable { + public let requestID: Int + public let method: String + public let attempt: Int + public let delay: Duration + public let serverError: CodexServerError + + package init( + requestID: Int, + method: String, + attempt: Int, + delay: Duration, + serverError: CodexServerError + ) { + precondition(attempt > 0, "A retry attempt is one-based.") + self.requestID = requestID + self.method = method + self.attempt = attempt + self.delay = delay + self.serverError = serverError + } +} + +/// A deprecation notice emitted by the pinned app-server protocol. +public struct CodexDeprecationNotice: Equatable, Sendable { + public let summary: String + public let details: String? + + package init(summary: String, details: String? = nil) { + self.summary = summary + self.details = details + } +} + +/// A root-bound, connection-scoped event subscription. +/// +/// The subscription does not retain the connection, its supervisor, or a connection lease. +/// Cancelling iteration only releases this subscriber. +/// Each subscriber keeps the newest 32 pending diagnostics. A terminal event supersedes +/// pending diagnostics, is delivered exactly once, and is the only event replayed to a late +/// subscriber. +public struct CodexConnectionEvents: AsyncSequence, Sendable { + public typealias Element = CodexConnectionEvent + + private let channel: ConnectionEventSubscriberChannel + private let cancellation: ConnectionEventSubscriptionCancellation + + fileprivate init( + channel: ConnectionEventSubscriberChannel, + cancellation: ConnectionEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + public func makeAsyncIterator() -> Iterator { + .init(channel: channel, cancellation: cancellation) + } + + public func cancel() async { + cancellation.cancel() + } + + package func waitUntilNextSuspendsForTesting() async { + await channel.waitUntilNextSuspendsForTesting() + } + + package func claimNextCallForTesting() -> Bool { + channel.tryBeginNext() + } + + package func endNextCallForTesting() { + channel.endNext() + } + + public struct Iterator: AsyncIteratorProtocol { + private let channel: ConnectionEventSubscriberChannel + private let cancellation: ConnectionEventSubscriptionCancellation + + fileprivate init( + channel: ConnectionEventSubscriberChannel, + cancellation: ConnectionEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + public mutating func next() async -> CodexConnectionEvent? { + await channel.next(cancellation: cancellation) + } + } +} + +/// Owns connection diagnostic fan-out and compact terminal replay. +/// +/// The termination winner is supplied by `ConnectionTerminationArbiter`; this hub never +/// arbitrates or replaces it. +package final class ConnectionEventHub: Sendable { + package struct Snapshot: Equatable, Sendable { + package var subscriberCount: Int + package var terminal: CodexConnectionTermination? + } + + private let subscriptionRegistry = ConnectionEventSubscriptionRegistry() + + package init() {} + + deinit { + subscriptionRegistry.cancelAll() + } + + package func events() -> CodexConnectionEvents { + subscriptionRegistry.makeEvents() + } + + package func yield(_ event: CodexConnectionEvent) { + if case .terminated = event { + preconditionFailure("Connection terminal delivery must use finish(with:).") + } + subscriptionRegistry.yield(event) + } + + package func finish(with termination: CodexConnectionTermination) { + subscriptionRegistry.finish(with: termination) + } + + package func snapshotForTesting() -> Snapshot { + subscriptionRegistry.snapshot() + } +} + +package enum ConnectionDiagnosticFactory { + package enum ProcessStderrFailureStage { + case setup + case read + } + + package static func routingFailure( + message: String, + method: String? = nil + ) -> CodexDiagnostic { + .init(message: message, method: method) + } + + package static func droppedNotification(method: String) -> CodexDiagnostic { + .init( + message: "Dropped notification while draining responses after routing failure.", + method: method + ) + } + + package static func droppedServerRequest( + id: CodexServerRequestID, + method: String + ) -> CodexDiagnostic { + .init( + message: "Dropped server request while draining responses after routing failure.", + method: method, + details: "requestId: \(requestIDDescription(id))" + ) + } + + package static func lateResponse(requestID: Int) -> CodexDiagnostic { + .init( + message: "Ignored late JSON-RPC response after outbound close.", + details: "requestId: \(requestID)" + ) + } + + package static func processStderrFailure( + _ stage: ProcessStderrFailureStage, + details: String + ) -> CodexDiagnostic { + let message = switch stage { + case .setup: "App-server stderr setup failed." + case .read: "App-server stderr read failed." + } + return .init(message: message, method: "process/stderr", details: details) + } + + package static func processStderr(_ event: AppServerStderrLogFilter.Event) -> CodexDiagnostic { + let severity = switch event.level { + case .error: "error" + case .warning: "warning" + } + return .init( + message: event.message, + method: "process/stderr", + details: "severity: \(severity)" + ) + } + + package static func lateTermination( + winner: CodexConnectionTermination, + candidate: CodexConnectionTermination + ) -> CodexDiagnostic { + .init( + message: "Ignored late connection termination.", + details: "winner: \(String(describing: winner)); candidate: \(String(describing: candidate))" + ) + } + + package static func serverRequestRegistry( + _ diagnostic: ServerRequestRegistry.Diagnostic + ) -> CodexDiagnostic { + switch diagnostic { + case .duplicateRequest(let id): + .init( + message: "Received a duplicate server-request identifier.", + details: "requestId: \(requestIDDescription(id))" + ) + case .rejectedWhileClosing(let id, let method): + .init( + message: "Rejected a server request while the connection was closing.", + method: method, + details: "requestId: \(requestIDDescription(id))" + ) + case .decodeFailed(let id, let method, let message): + .init( + message: "Failed to decode a server request.", + method: method, + details: "requestId: \(requestIDDescription(id)); \(message)" + ) + case .handlerFailed(let id, let method, let message): + .init( + message: "Server-request handler failed.", + method: method, + details: "requestId: \(requestIDDescription(id)); \(message)" + ) + case .responseFailed(let id, let method, let message): + .init( + message: "Failed to write a server-request response.", + method: method, + details: "requestId: \(requestIDDescription(id)); \(message)" + ) + case .ownedTaskRequestedClose(let id): + .init( + message: "A server-request handler requested connection close.", + details: "requestId: \(requestIDDescription(id))" + ) + } + } + + private static func requestIDDescription(_ id: CodexServerRequestID) -> String { + switch id { + case .integer(let value): String(value) + case .string(let value): value + } + } +} + +private final class ConnectionEventSubscriberChannel: Sendable { + private enum Phase { + case open + case terminalPending(CodexConnectionTermination) + case finished(CodexConnectionTermination) + case cancelled + } + + private struct State { + var pending: [CodexConnectionEvent] = [] + var waiter: CheckedContinuation? + var suspensionObservers: [CheckedContinuation] = [] + var nextIsActive = false + var phase = Phase.open + } + + private static let diagnosticCapacity = 32 + private let state = Mutex(State()) + + func yield(_ event: CodexConnectionEvent) { + let waiter = state.withLock { state -> CheckedContinuation< + CodexConnectionEvent?, Never + >? in + guard case .open = state.phase else { + return nil + } + if let waiter = state.waiter { + state.waiter = nil + return waiter + } + if state.pending.count == Self.diagnosticCapacity { + state.pending.removeFirst() + } + state.pending.append(event) + return nil + } + waiter?.resume(returning: event) + } + + func finish(with termination: CodexConnectionTermination) { + let completion = state.withLock { state -> ( + CheckedContinuation?, + [CheckedContinuation] + ) in + switch state.phase { + case .open: + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + let observers = state.suspensionObservers + state.suspensionObservers.removeAll(keepingCapacity: false) + state.phase = waiter == nil ? .terminalPending(termination) : .finished(termination) + return (waiter, observers) + case .terminalPending(let existing), .finished(let existing): + precondition( + existing == termination, + "A connection subscriber cannot receive conflicting terminal reasons." + ) + return (nil, []) + case .cancelled: + return (nil, []) + } + } + for observer in completion.1 { + observer.resume() + } + completion.0?.resume(returning: .terminated(termination)) + } + + func cancel() { + let completion = state.withLock { state -> ( + CheckedContinuation?, + [CheckedContinuation] + ) in + guard case .cancelled = state.phase else { + if case .finished = state.phase { + return (nil, []) + } + state.phase = .cancelled + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + let observers = state.suspensionObservers + state.suspensionObservers.removeAll(keepingCapacity: false) + return (waiter, observers) + } + return (nil, []) + } + for observer in completion.1 { + observer.resume() + } + completion.0?.resume(returning: nil) + } + + func next( + cancellation: ConnectionEventSubscriptionCancellation + ) async -> CodexConnectionEvent? { + precondition( + tryBeginNext(), + "CodexConnectionEvents supports one in-flight next() call." + ) + defer { endNext() } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let registration = state.withLock { state -> ( + CodexConnectionEvent??, + [CheckedContinuation] + ) in + if state.pending.isEmpty == false { + return (state.pending.removeFirst(), []) + } + switch state.phase { + case .open: + precondition(state.waiter == nil) + state.waiter = continuation + let observers = state.suspensionObservers + state.suspensionObservers.removeAll(keepingCapacity: false) + return (nil, observers) + case .terminalPending(let termination): + state.phase = .finished(termination) + return (.some(.terminated(termination)), []) + case .finished, .cancelled: + return (.some(nil), []) + } + } + for observer in registration.1 { + observer.resume() + } + if let immediate = registration.0 { + continuation.resume(returning: immediate) + } + } + } onCancel: { + cancellation.cancel() + } + } + + func tryBeginNext() -> Bool { + state.withLock { state in + guard state.nextIsActive == false else { + return false + } + state.nextIsActive = true + return true + } + } + + func endNext() { + state.withLock { state in + precondition(state.nextIsActive, "A next() call must own the channel before ending.") + state.nextIsActive = false + } + } + + func waitUntilNextSuspendsForTesting() async { + await withCheckedContinuation { continuation in + let isAlreadySuspendedOrFinished = state.withLock { state in + if state.waiter != nil { + return true + } + guard case .open = state.phase else { + return true + } + state.suspensionObservers.append(continuation) + return false + } + if isAlreadySuspendedOrFinished { + continuation.resume() + } + } + } +} + +private final class ConnectionEventSubscriptionCancellation: Sendable { + private struct State { + var isCancelled = false + } + + private let state = Mutex(State()) + private let id: UUID + private let registry: ConnectionEventSubscriptionRegistry + + init(id: UUID, registry: ConnectionEventSubscriptionRegistry) { + self.id = id + self.registry = registry + } + + func cancel() { + let shouldRemove = state.withLock { state in + guard state.isCancelled == false else { + return false + } + state.isCancelled = true + return true + } + if shouldRemove { + registry.remove(id) + } + } + + deinit { + cancel() + } +} + +private final class ConnectionEventSubscriptionRegistry: Sendable { + private struct State { + var channels: [UUID: ConnectionEventSubscriberChannel] = [:] + var terminal: CodexConnectionTermination? + } + + private let state = Mutex(State()) + // `remove` and `cancelAll` must never acquire `delivery`: channel completion can wait on a + // subscriber task-status lock while that lock runs its cancellation handler back into remove. + private let delivery = Mutex(()) + + func makeEvents() -> CodexConnectionEvents { + let id = UUID() + let channel = ConnectionEventSubscriberChannel() + let cancellation = ConnectionEventSubscriptionCancellation(id: id, registry: self) + delivery.withLock { _ in + let terminal = state.withLock { state -> CodexConnectionTermination? in + guard let terminal = state.terminal else { + state.channels[id] = channel + return nil + } + return terminal + } + if let terminal { + channel.finish(with: terminal) + } + } + return .init(channel: channel, cancellation: cancellation) + } + + func yield(_ event: CodexConnectionEvent) { + delivery.withLock { _ in + let channels = state.withLock { state -> [ConnectionEventSubscriberChannel] in + guard state.terminal == nil else { + return [] + } + return Array(state.channels.values) + } + for channel in channels { + channel.yield(event) + } + } + } + + func remove(_ id: UUID) { + let channel = state.withLock { $0.channels.removeValue(forKey: id) } + channel?.cancel() + } + + func finish(with termination: CodexConnectionTermination) { + delivery.withLock { _ in + let channels = state.withLock { state -> [ConnectionEventSubscriberChannel] in + if let existing = state.terminal { + precondition( + existing == termination, + "ConnectionEventHub cannot replace its derived terminal replay." + ) + return [] + } + state.terminal = termination + let channels = Array(state.channels.values) + state.channels.removeAll(keepingCapacity: false) + return channels + } + for channel in channels { + channel.finish(with: termination) + } + } + } + + func cancelAll() { + let channels = state.withLock { state -> [ConnectionEventSubscriberChannel] in + let channels = Array(state.channels.values) + state.channels.removeAll(keepingCapacity: false) + return channels + } + for channel in channels { + channel.cancel() + } + } + + func snapshot() -> ConnectionEventHub.Snapshot { + state.withLock { state in + .init(subscriberCount: state.channels.count, terminal: state.terminal) + } + } +} diff --git a/Sources/CodexAppServerKit/ConnectionSupervisor.swift b/Sources/CodexAppServerKit/ConnectionSupervisor.swift new file mode 100644 index 00000000..838eaa2d --- /dev/null +++ b/Sources/CodexAppServerKit/ConnectionSupervisor.swift @@ -0,0 +1,326 @@ +import Foundation +import OSLog +import Synchronization + +private let supervisorLogger = Logger( + subsystem: "CodexAppServerKit", + category: "connection-supervisor" +) + +package final class ConnectionCloseAction: Sendable { + private enum Request: Sendable { + case close + case fail(CodexTransportFailure) + } + + private let action: Mutex<(@Sendable (Request) async -> Void)?> + + package init(action: (@Sendable () async -> Void)? = nil) { + let wrappedAction: (@Sendable (Request) async -> Void)? + if let action { + wrappedAction = { _ in await action() } + } else { + wrappedAction = nil + } + self.action = Mutex(wrappedAction) + } + + package func bind(to supervisor: ConnectionSupervisor) { + action.withLock { action in + precondition(action == nil, "Connection close action may be bound exactly once.") + action = { [weak supervisor] request in + guard let supervisor else { + preconditionFailure( + "Connection supervisor must outlive every client operation." + ) + } + switch request { + case .close: + await supervisor.closeConnection() + case .fail(let failure): + await supervisor.failConnection(with: failure) + } + } + } + } + + package func closeConnection() async { + guard let action = action.withLock({ $0 }) else { + preconditionFailure("Connection close action is not bound.") + } + await action(.close) + } + + package func failConnection(with failure: CodexTransportFailure) async { + guard let action = action.withLock({ $0 }) else { + preconditionFailure("Connection close action is not bound.") + } + await action(.fail(failure)) + } +} + +package actor ConnectionSupervisor { + private enum Phase: Equatable { + case initialized + case running + case linearizingClose + case closing + case closed + } + + private let connection: AppServerConnection + private let connectionEventHub: ConnectionEventHub + private var phase: Phase = .initialized + private var routerTask: Task? + private var processExitTask: Task? + private var closeTask: Task? + private var terminationArbiter = ConnectionTerminationArbiter() + private var terminationWaiters: + [CheckedContinuation] = [] + + package init(connection: AppServerConnection) { + self.connection = connection + self.connectionEventHub = connection.connectionEventHub + } + + deinit { + routerTask?.cancel() + processExitTask?.cancel() + closeTask?.cancel() + } + + package func start() { + guard phase == .initialized else { + preconditionFailure("ConnectionSupervisor.start() may be called exactly once.") + } + phase = .running + let connection = connection + routerTask = Task { [weak self, connection] in + await connection.runInboundEvents { [weak self] signal in + await self?.recordExitSignal(signal) + } + } + processExitTask = Task { [weak self, connection] in + let observation = await connection.waitForProcessExit() + guard Task.isCancelled == false else { + return + } + switch observation { + case .unavailable: + return + case .exited(let status, let observedBeforeTermination): + await self?.recordExitSignal(.processExited( + status: status, + observedBeforeTermination: observedBeforeTermination + )) + case .failed(let failure): + await self?.recordExitSignal(.transport(failure)) + } + } + } + + package func closeConnection() async { + let completion = recordTermination(.init(.closedByCaller)) + if let context = ServerRequestTaskContext.value, + await connection.signalCloseIfOwned(by: context) { + return + } + await completion.value + } + + package func failConnection(with failure: CodexTransportFailure) async { + let completion = recordTermination(.init(.transportFailure(failure))) + if let context = ServerRequestTaskContext.value, + await connection.signalCloseIfOwned(by: context) { + return + } + await completion.value + } + + package func waitUntilClosed() async { + guard let closeTask else { + preconditionFailure("Connection close has not started.") + } + await closeTask.value + } + + package func serverRequestChildCount() async -> Int { + await connection.serverRequestChildCount() + } + + package func terminationForTesting() -> CodexConnectionTermination? { + terminationArbiter.winner + } + + package func waitForTerminationForTesting() async -> CodexConnectionTermination { + if let winner = terminationArbiter.winner { + return winner + } + return await withCheckedContinuation { continuation in + terminationWaiters.append(continuation) + } + } + + private func recordExitSignal(_ signal: ConnectionExitSignal) { + _ = recordTermination(signal.terminationCandidate) + } + + @discardableResult + private func recordTermination( + _ candidate: ConnectionTerminationArbiter.Candidate + ) -> Task { + switch terminationArbiter.claim(candidate) { + case .accepted: + precondition( + phase == .running, + "Connection termination can start only from the running phase." + ) + phase = .linearizingClose + return startCloseTask() + case .refined: + guard let closeTask else { + preconditionFailure("A provisional terminal must publish its close task atomically.") + } + return closeTask + case .duplicate: + guard let closeTask else { + preconditionFailure("A provisional terminal must publish its close task atomically.") + } + return closeTask + case .late(let winner, let candidate): + if winner.termination != candidate.termination { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.lateTermination( + winner: winner.termination, + candidate: candidate.termination + ) + )) + supervisorLogger.debug( + "Ignoring late connection termination: \(String(describing: candidate.termination), privacy: .public); winner: \(String(describing: winner.termination), privacy: .public)" + ) + } + guard let closeTask else { + preconditionFailure("A terminal reason must publish its close task atomically.") + } + return closeTask + } + } + + private func startCloseTask() -> Task { + if let closeTask { + return closeTask + } + let connection = connection + let task = Task { [weak self, connection] in + let observedAtClose = await connection.beginClose() + guard let self else { + return + } + await self.commitAndRunFullClose(observedAtClose: observedAtClose) + } + closeTask = task + return task + } + + private func commitAndRunFullClose( + observedAtClose: JSONRPC.ProcessExitObservation? + ) async { + let termination = terminationArbiter.commit( + closeObservation: Self.closeObservation(observedAtClose) + ) + let terminationWaiters = terminationWaiters + self.terminationWaiters.removeAll(keepingCapacity: false) + for waiter in terminationWaiters { + waiter.resume(returning: termination) + } + phase = .closing + await runFullClose(termination: termination) + } + + private func runFullClose(termination: CodexConnectionTermination) async { + await routerTask?.value + await connection.finishPendingResponsesAfterInboundDrain( + Self.pendingResponseFailure(for: termination) + ) + await connection.cancelServerRequestsAndWait() + let serverRequestChildCount = await connection.serverRequestChildCount() + precondition( + serverRequestChildCount == 0, + "Server-request registry must be empty before domain termination." + ) + await connection.finishDomains(with: termination) + await connection.waitUntilTransportClosed() + await processExitTask?.value + await connection.reapProcess() + phase = .closed + } + + private nonisolated static func closeObservation( + _ observation: JSONRPC.ProcessExitObservation? + ) -> ConnectionTerminationArbiter.CloseObservation? { + switch observation { + case nil: + nil + case .unavailable: + .unavailable + case .exited(let status, let observedBeforeTermination): + .exited( + status: status, + observedBeforeTermination: observedBeforeTermination + ) + case .failed(let failure): + .failed(failure) + } + } + + private nonisolated static func pendingResponseFailure( + for termination: CodexConnectionTermination + ) -> CodexTransportFailure { + switch termination { + case .closedByCaller: + .closed + case .transportFailure(let failure): + failure + case .processExited(let status): + .io( + errno: nil, + message: status.map { "App-server process exited with status \($0)." } + ?? "App-server process exited." + ) + } + } +} + +package final class AppServerConnectionLease: Sendable { + private struct State { + var supervisor: ConnectionSupervisor + var processTerminationToken: ProcessTerminationToken + } + + private let state: Mutex + + package init( + supervisor: ConnectionSupervisor, + processTerminationToken: ProcessTerminationToken + ) { + self.state = Mutex(.init( + supervisor: supervisor, + processTerminationToken: processTerminationToken + )) + } + + deinit { + state.withLock { state in + state.processTerminationToken.terminateOnce() + } + } + + package func closeConnection() async { + let supervisor = state.withLock { $0.supervisor } + await supervisor.closeConnection() + } + + package var supervisor: ConnectionSupervisor { + state.withLock { $0.supervisor } + } +} diff --git a/Sources/CodexAppServerKit/ConnectionTerminationArbiter.swift b/Sources/CodexAppServerKit/ConnectionTerminationArbiter.swift new file mode 100644 index 00000000..c488b6e4 --- /dev/null +++ b/Sources/CodexAppServerKit/ConnectionTerminationArbiter.swift @@ -0,0 +1,161 @@ +/// Linearizes connection termination on `ConnectionSupervisor` isolation. +/// +/// A transport EOF is provisional until `beginClose()` probes the process. That probe is +/// the only refinement window: an exit observed before termination started, or a probe +/// failure, is more specific than `.transportFailure(.closed)`. Every other first candidate +/// is nonreplaceable. +package struct ConnectionTerminationArbiter { + package struct Candidate: Equatable, Sendable { + package var termination: CodexConnectionTermination + package var observedBeforeTermination: Bool + + package init( + _ termination: CodexConnectionTermination, + observedBeforeTermination: Bool = false + ) { + if observedBeforeTermination { + guard case .processExited = termination else { + preconditionFailure( + "Only a process-exit candidate can be observed before termination." + ) + } + } + self.termination = termination + self.observedBeforeTermination = observedBeforeTermination + } + } + + package enum CloseObservation: Equatable, Sendable { + case unavailable + case exited(status: Int32?, observedBeforeTermination: Bool) + case failed(CodexTransportFailure) + } + + package enum Claim: Equatable, Sendable { + case accepted(Candidate) + case refined(previous: Candidate, winner: Candidate) + case duplicate(Candidate) + case late( + winner: Candidate, + candidate: Candidate + ) + } + + private enum State: Equatable, Sendable { + case open + case provisional(Candidate) + case committed(Candidate) + } + + private var state = State.open + + package init() {} + + package var provisionalCandidate: Candidate? { + guard case .provisional(let candidate) = state else { + return nil + } + return candidate + } + + package var winner: CodexConnectionTermination? { + guard case .committed(let candidate) = state else { + return nil + } + return candidate.termination + } + + package mutating func claim(_ candidate: Candidate) -> Claim { + switch state { + case .open: + state = .provisional(candidate) + return .accepted(candidate) + case .provisional(let winner): + guard winner != candidate else { + return .duplicate(winner) + } + if let refinement = Self.signalRefinement(of: winner, with: candidate) { + state = .provisional(refinement) + return .refined(previous: winner, winner: refinement) + } + return .late(winner: winner, candidate: candidate) + case .committed(let winner): + guard winner.termination != candidate.termination else { + return .duplicate(winner) + } + return .late(winner: winner, candidate: candidate) + } + } + + /// Commits the terminal reason after the first candidate's `beginClose()` probe. + /// + /// Repeated commits are idempotent. Their observation cannot reopen arbitration. + package mutating func commit( + closeObservation: CloseObservation? + ) -> CodexConnectionTermination { + switch state { + case .open: + preconditionFailure("Termination cannot commit before a candidate is accepted.") + case .provisional(let candidate): + let winner: Candidate + if let observationCandidate = Self.candidate(from: closeObservation), + let refinement = Self.closeRefinement( + of: candidate, + with: observationCandidate + ) { + winner = refinement + } else { + winner = candidate + } + state = .committed(winner) + return winner.termination + case .committed(let winner): + return winner.termination + } + } + + private static func signalRefinement( + of winner: Candidate, + with candidate: Candidate + ) -> Candidate? { + guard winner.termination == .transportFailure(.closed) else { + return nil + } + guard case .processExited = candidate.termination, + candidate.observedBeforeTermination else { + return nil + } + return candidate + } + + private static func closeRefinement( + of winner: Candidate, + with candidate: Candidate + ) -> Candidate? { + if let signalRefinement = signalRefinement(of: winner, with: candidate) { + return signalRefinement + } + guard winner.termination == .transportFailure(.closed), + case .transportFailure(let failure) = candidate.termination, + failure != .closed else { + return nil + } + return candidate + } + + private static func candidate( + from closeObservation: CloseObservation? + ) -> Candidate? { + switch closeObservation { + case nil, .unavailable: + nil + case .exited(let status, let observedBeforeTermination): + Candidate( + .processExited(status: status), + observedBeforeTermination: observedBeforeTermination + ) + case .failed(let failure): + Candidate(.transportFailure(failure)) + } + } +} diff --git a/Sources/CodexAppServerKit/InterruptRaceResolver.swift b/Sources/CodexAppServerKit/InterruptRaceResolver.swift new file mode 100644 index 00000000..b53ede51 --- /dev/null +++ b/Sources/CodexAppServerKit/InterruptRaceResolver.swift @@ -0,0 +1,75 @@ +import Foundation + +package struct InterruptRaceResolver: Sendable { + package enum Decision: Sendable { + case retry(after: Duration) + case redirect(to: CodexTurnID) + case fail + } + + private static let noActiveTurnMessage = "no active turn to interrupt" + private static let activationRetryLimit = 5 + private static let activationRetryDelay = Duration.milliseconds(50) + + private let expectedTurnID: CodexTurnID? + private var activationRetryCount = 0 + private var redirected = false + + package init(expectedTurnID: CodexTurnID?) { + self.expectedTurnID = expectedTurnID + } + + package mutating func decision(for error: any Error) -> Decision { + guard let message = Self.serverError(from: error)?.message else { + return .fail + } + if expectedTurnID != nil, + message == Self.noActiveTurnMessage, + activationRetryCount < Self.activationRetryLimit { + activationRetryCount += 1 + return .retry(after: Self.activationRetryDelay) + } + // The pinned app-server treats an empty turn ID as a startup interrupt + // and bypasses its expected-turn mismatch check. Only a nonempty ID we + // actually sent can authenticate the exact mismatch prefix below. + guard redirected == false, + let expectedTurnID, + let activeTurnID = Self.activeTurnID( + in: message, + expectedTurnID: expectedTurnID + ), + activeTurnID != expectedTurnID else { + return .fail + } + redirected = true + return .redirect(to: activeTurnID) + } + + private static func activeTurnID( + in message: String, + expectedTurnID: CodexTurnID + ) -> CodexTurnID? { + let prefix = "expected active turn id \(expectedTurnID.rawValue) but found " + guard message.hasPrefix(prefix) else { + return nil + } + let rawValue = String(message.dropFirst(prefix.count)) + guard rawValue.isEmpty == false, + rawValue.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, + rawValue.contains("`") == false else { + return nil + } + return CodexTurnID(rawValue: rawValue) + } + + private static func serverError(from error: any Error) -> CodexServerError? { + if case CodexAppServerError.request(let failure) = error, + case .server(let serverError) = failure.kind { + return serverError + } + if case JSONRPC.Error.responseError(let serverError) = error { + return serverError + } + return nil + } +} diff --git a/Sources/CodexAppServerKit/JSONRPC.swift b/Sources/CodexAppServerKit/JSONRPC.swift new file mode 100644 index 00000000..4e4327b6 --- /dev/null +++ b/Sources/CodexAppServerKit/JSONRPC.swift @@ -0,0 +1,150 @@ +import Foundation + +package enum JSONRPC { + package struct Request: Equatable, Sendable { + package var id: Int + package var method: String + package var params: Data + + package init(id: Int, method: String, params: Data) { + self.id = id + self.method = method + self.params = params + } + } + + package struct Notification: Equatable, Sendable { + package var method: String + package var params: Data + + package init(method: String, params: Data) { + self.method = method + self.params = params + } + } + + package enum InboundEvent: Equatable, Sendable { + case notification(Notification) + case serverRequest( + id: CodexServerRequestID, + method: String, + params: Data + ) + } + + package enum ProcessExitObservation: Equatable, Sendable { + case unavailable + case exited(status: Int32?, observedBeforeTermination: Bool) + case failed(CodexTransportFailure) + } + + package protocol Transport: Sendable { + var connectionEventHub: ConnectionEventHub { get } + func send( + _ request: Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data + func notify(_ notification: Notification) async throws + func nextInboundEvent() async throws -> InboundEvent? + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws + func beginClose() async -> ProcessExitObservation? + func finishPendingResponsesAfterInboundDrain( + _ failure: CodexTransportFailure + ) async + func waitForProcessExit() async -> ProcessExitObservation + func waitUntilClosed() async + func reapProcess() async + } + + package enum Error: Swift.Error, Equatable, Sendable, LocalizedError { + case closed + case invalidMessage(String) + case responseError(CodexServerError) + + package var errorDescription: String? { + switch self { + case .closed: + "JSON-RPC transport is closed." + case .invalidMessage(let message): + "Invalid JSON-RPC message: \(message)" + case .responseError(let error): + error.message + } + } + } + + package struct OutboundWriteFailure: Swift.Error, Equatable, Sendable { + package var failure: CodexTransportFailure + + package init(_ failure: CodexTransportFailure) { + self.failure = failure + } + } + + package struct Framer: Sendable { + package static let maximumFrameByteCount = 16 * 1_024 * 1_024 + + private var buffer = Data() + private let maximumFrameByteCount: Int + + package init(maximumFrameByteCount: Int = Self.maximumFrameByteCount) { + precondition(maximumFrameByteCount > 0) + self.maximumFrameByteCount = maximumFrameByteCount + } + + package mutating func append(_ byte: UInt8) throws -> Data? { + if byte == 0x0A { + guard buffer.isEmpty == false else { + return nil + } + let frame = buffer + buffer.removeAll(keepingCapacity: true) + return frame + } + guard buffer.count < maximumFrameByteCount else { + buffer.removeAll(keepingCapacity: false) + throw CodexTransportFailure.framing( + message: "JSON-RPC frame exceeds \(maximumFrameByteCount) bytes.", + rawData: nil + ) + } + buffer.append(byte) + return nil + } + + package mutating func finish() -> Data? { + guard buffer.isEmpty == false else { + return nil + } + defer { buffer.removeAll(keepingCapacity: false) } + return buffer + } + } +} + +package extension JSONRPC.Transport { + func send(_ request: JSONRPC.Request) async throws -> Data { + try await send(request, acceptWrite: {}) + } +} + +package struct AnyEncodable: Encodable { + private let encodeValue: @Sendable (Encoder) throws -> Void + + package init(_ value: Value) { + self.encodeValue = { encoder in + try value.encode(to: encoder) + } + } + + package func encode(to encoder: Encoder) throws { + try encodeValue(encoder) + } +} + +package struct EmptyResponse: Codable, Equatable, Sendable { + package init() {} +} diff --git a/Sources/CodexAppServerKit/JSONRPCEnvelopeCodec.swift b/Sources/CodexAppServerKit/JSONRPCEnvelopeCodec.swift new file mode 100644 index 00000000..7df32147 --- /dev/null +++ b/Sources/CodexAppServerKit/JSONRPCEnvelopeCodec.swift @@ -0,0 +1,220 @@ +import CoreFoundation +import Foundation +import Synchronization + +package extension JSONRPC { + enum RawInboundEnvelope: Sendable { + case response(id: Int, Result) + case event(InboundEvent) + } + + static func decodeInboundEnvelope(_ data: Data) throws -> RawInboundEnvelope { + let object: [String: Any] + do { + guard let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC envelope must be an object.", + rawData: data + ) + } + object = decoded + } catch let failure as CodexTransportFailure { + throw failure + } catch { + throw CodexTransportFailure.protocolViolation( + message: "Invalid JSON-RPC envelope: \(error.localizedDescription)", + rawData: data + ) + } + + if let method = object["method"] as? String { + let params = try payloadData(from: object["params"] ?? [:]) + if object.keys.contains("id") { + guard let id = CodexServerRequestID(jsonObject: object["id"]) else { + throw CodexTransportFailure.protocolViolation( + message: "Server request \(method) has an invalid id.", + rawData: data + ) + } + return .event(.serverRequest(id: id, method: method, params: params)) + } + return .event(.notification(.init(method: method, params: params))) + } + + guard let id = exactInteger(object["id"]) else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC response has no integer id.", + rawData: data + ) + } + let hasResult = object.keys.contains("result") + let hasError = object.keys.contains("error") + guard hasResult != hasError else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC response must contain exactly one of result or error.", + rawData: data + ) + } + if hasError { + guard let errorObject = object["error"] as? [String: Any] else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC response error must be an object.", + rawData: data + ) + } + guard let code = exactInteger(errorObject["code"]) else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC response error code must be an integer.", + rawData: data + ) + } + guard let message = errorObject["message"] as? String else { + throw CodexTransportFailure.protocolViolation( + message: "JSON-RPC response error message must be a string.", + rawData: data + ) + } + let rawData = try errorObject["data"].map(payloadData(from:)) + let turnError = rawData + .flatMap { try? JSONDecoder().decode(AppServerAPI.Turn.Error.self, from: $0) } + .map(CodexAppServer.turnError(from:)) + return .response( + id: id, + .failure(.responseError(.init( + code: code, + message: message, + data: rawData, + turnError: turnError + ))) + ) + } + let result = try payloadData(from: object["result"]!) + return .response(id: id, .success(result)) + } + + static func responseFrame( + id: Int, + result: Result + ) throws -> Data { + let object: [String: Any] + switch result { + case .success(let data): + object = [ + "id": id, + "result": try JSONSerialization.jsonObject( + with: data, + options: [.fragmentsAllowed] + ), + ] + case .failure(.responseError(let error)): + var errorObject: [String: Any] = [ + "code": error.code, + "message": error.message, + ] + if let data = error.data { + errorObject["data"] = try JSONSerialization.jsonObject( + with: data, + options: [.fragmentsAllowed] + ) + } + object = ["id": id, "error": errorObject] + case .failure(.closed): + throw CodexTransportFailure.closed + case .failure(.invalidMessage(let message)): + throw CodexTransportFailure.protocolViolation(message: message, rawData: nil) + } + return try JSONSerialization.data(withJSONObject: object) + } + + static func notificationFrame(_ notification: Notification) throws -> Data { + let params = try JSONSerialization.jsonObject( + with: notification.params, + options: [.fragmentsAllowed] + ) + return try JSONSerialization.data(withJSONObject: [ + "method": notification.method, + "params": params, + ]) + } + + static func serverRequestFrame( + id: CodexServerRequestID, + method: String, + params: Data + ) throws -> Data { + let paramsObject = try JSONSerialization.jsonObject( + with: params, + options: [.fragmentsAllowed] + ) + return try JSONSerialization.data(withJSONObject: [ + "id": id.jsonObject, + "method": method, + "params": paramsObject, + ]) + } + + static func payloadData(from value: Any) throws -> Data { + return try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]) + } + + private static func exactInteger(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let integer = number.int64Value + guard number.doubleValue.isFinite, + number.doubleValue == Double(integer) else { + return nil + } + return Int(exactly: integer) + } +} + +package final class JSONRPCResponseWaiter: Sendable { + private enum State { + case pending(CheckedContinuation, Never>?) + case resolved(Result) + } + + private let state = Mutex(.pending(nil)) + + package init() {} + + package func wait() async throws -> Data { + let result = await withCheckedContinuation { continuation in + let resolved = state.withLock { + state -> Result? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("JSON-RPC response waiter registered twice.") + case .resolved(let result): + return result + } + } + if let resolved { + continuation.resume(returning: resolved) + } + } + return try result.get() + } + + @discardableResult + package func resolve(_ result: Result) -> Bool { + let resolution = state.withLock { + state -> (Bool, CheckedContinuation, Never>?) in + switch state { + case .pending(let continuation): + state = .resolved(result) + return (true, continuation) + case .resolved: + return (false, nil) + } + } + resolution.1?.resume(returning: result) + return resolution.0 + } +} diff --git a/Sources/CodexAppServerKit/JSONRPCInboundFrameMailbox.swift b/Sources/CodexAppServerKit/JSONRPCInboundFrameMailbox.swift new file mode 100644 index 00000000..e12334fe --- /dev/null +++ b/Sources/CodexAppServerKit/JSONRPCInboundFrameMailbox.swift @@ -0,0 +1,337 @@ +import Foundation +import Synchronization + +package actor JSONRPCInboundFrameMailbox { + package struct Snapshot: Equatable, Sendable { + package var readyFrameCount: Int + package var hasOverflowFrame: Bool + package var admissionWaiterCount: Int + package var isTerminal: Bool + + package var acceptedFrameCount: Int { + readyFrameCount + (hasOverflowFrame ? 1 : 0) + } + } + + private enum Phase { + case open + case terminal(CodexTransportFailure?) + } + + private enum AdmissionOutcome: Sendable { + case granted + case cancelled + case closed + } + + private enum ReceiveOutcome: Sendable { + case frame(Data) + case end + case failure(CodexTransportFailure) + case cancelled + } + + private let readyCapacity: Int + private var phase: Phase = .open + private var readyFrames: [Data] = [] + private var overflowFrame: Data? + private var admissionWaiters: [MailboxWaiter] = [] + private var admissionWaiterCountWaiters: [(Int, CheckedContinuation)] = [] + private var grantedAdmissionID: UUID? + private var receiver: MailboxWaiter? + private var receiverRegistrationWaiters: [CheckedContinuation] = [] + private var terminalDelivered = false + + package init(readyCapacity: Int = 16) { + precondition(readyCapacity > 0) + self.readyCapacity = readyCapacity + self.readyFrames.reserveCapacity(readyCapacity) + } + + package func send(_ frame: Data) async throws { + try Task.checkCancellation() + guard case .open = phase else { + throw CodexTransportFailure.closed + } + + if grantedAdmissionID == nil, hasAvailableAcceptedSlot { + accept(frame) + return + } + + let waiter = MailboxWaiter(cancellationValue: .cancelled) + admissionWaiters.append(waiter) + resumeAdmissionWaiterCountWaiters() + let outcome = await waiter.wait() + admissionWaiters.removeAll { $0.id == waiter.id } + + switch outcome { + case .granted: + guard grantedAdmissionID == waiter.id else { + if case .terminal = phase { + throw CodexTransportFailure.closed + } + preconditionFailure("Mailbox granted an admission token it does not own.") + } + grantedAdmissionID = nil + guard case .open = phase else { + grantNextAdmissionIfPossible() + throw CodexTransportFailure.closed + } + guard Task.isCancelled == false else { + grantNextAdmissionIfPossible() + throw CancellationError() + } + guard hasAvailableAcceptedSlot else { + preconditionFailure("Granted mailbox admission has no reserved capacity.") + } + accept(frame) + case .cancelled: + throw CancellationError() + case .closed: + throw CodexTransportFailure.closed + } + } + + package func next() async throws -> Data? { + try Task.checkCancellation() + if let frame = takeReadyFrame() { + return frame + } + if let terminal = terminalOutcomeIfDrained() { + return try terminal.get() + } + precondition(receiver == nil, "JSON-RPC inbound mailbox supports one consumer.") + + let waiter = MailboxWaiter(cancellationValue: .cancelled) + receiver = waiter + let receiverRegistrationWaiters = receiverRegistrationWaiters + self.receiverRegistrationWaiters.removeAll(keepingCapacity: false) + for continuation in receiverRegistrationWaiters { + continuation.resume() + } + let outcome = await waiter.wait() + if receiver?.id == waiter.id { + receiver = nil + } + switch outcome { + case .frame(let frame): + return frame + case .end: + return nil + case .failure(let failure): + throw failure + case .cancelled: + throw CancellationError() + } + } + + package func finish(throwing failure: CodexTransportFailure? = nil) { + guard case .open = phase else { + return + } + phase = .terminal(failure) + for waiter in admissionWaiters { + _ = waiter.resolve(.closed) + } + admissionWaiters.removeAll(keepingCapacity: false) + grantedAdmissionID = nil + resumeReceiverIfPossible() + } + + package func snapshot() -> Snapshot { + Snapshot( + readyFrameCount: readyFrames.count, + hasOverflowFrame: overflowFrame != nil, + admissionWaiterCount: admissionWaiters.count, + isTerminal: { + if case .terminal = phase { + return true + } + return false + }() + ) + } + + package func waitForAdmissionWaiterCount(atLeast minimumCount: Int) async { + guard admissionWaiters.count < minimumCount else { + return + } + await withCheckedContinuation { continuation in + admissionWaiterCountWaiters.append((minimumCount, continuation)) + } + } + + package func waitUntilReceiverIsRegistered() async { + guard receiver == nil else { + return + } + await withCheckedContinuation { continuation in + receiverRegistrationWaiters.append(continuation) + } + } + + private var hasAvailableAcceptedSlot: Bool { + readyFrames.count < readyCapacity || overflowFrame == nil + } + + private func accept(_ frame: Data) { + guard readyFrames.count < readyCapacity || overflowFrame == nil else { + preconditionFailure("JSON-RPC inbound mailbox exceeded its accepted capacity.") + } + if readyFrames.count < readyCapacity { + readyFrames.append(frame) + } else { + overflowFrame = frame + } + resumeReceiverIfPossible() + } + + private func takeReadyFrame() -> Data? { + promoteOverflowIfPossible() + guard readyFrames.isEmpty == false else { + return nil + } + let frame = readyFrames.removeFirst() + promoteOverflowIfPossible() + grantNextAdmissionIfPossible() + resumeReceiverIfPossible() + return frame + } + + private func promoteOverflowIfPossible() { + guard readyFrames.count < readyCapacity, let overflowFrame else { + return + } + readyFrames.append(overflowFrame) + self.overflowFrame = nil + } + + private func grantNextAdmissionIfPossible() { + guard case .open = phase, + grantedAdmissionID == nil, + hasAvailableAcceptedSlot else { + return + } + while admissionWaiters.isEmpty == false { + let waiter = admissionWaiters.removeFirst() + if waiter.resolve(.granted) { + grantedAdmissionID = waiter.id + return + } + } + } + + private func resumeAdmissionWaiterCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in admissionWaiterCountWaiters { + if admissionWaiters.count >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + admissionWaiterCountWaiters = remaining + } + + private func resumeReceiverIfPossible() { + guard let receiver else { + return + } + promoteOverflowIfPossible() + if let frame = readyFrames.first { + guard receiver.resolve(.frame(frame)) else { + self.receiver = nil + return + } + self.receiver = nil + readyFrames.removeFirst() + promoteOverflowIfPossible() + grantNextAdmissionIfPossible() + return + } + guard case .terminal(let failure) = phase else { + return + } + let didResolve: Bool + if let failure { + didResolve = receiver.resolve(.failure(failure)) + } else { + didResolve = receiver.resolve(.end) + } + self.receiver = nil + if didResolve { + terminalDelivered = true + } + } + + private func terminalOutcomeIfDrained() -> Result? { + promoteOverflowIfPossible() + guard readyFrames.isEmpty, overflowFrame == nil, + case .terminal(let failure) = phase else { + return nil + } + guard terminalDelivered == false else { + return .success(nil) + } + terminalDelivered = true + if let failure { + return .failure(failure) + } + return .success(nil) + } +} + +private final class MailboxWaiter: Sendable { + private enum State { + case pending(CheckedContinuation?) + case resolved(Value) + } + + let id = UUID() + private let cancellationValue: Value + private let state = Mutex(.pending(nil)) + + init(cancellationValue: Value) { + self.cancellationValue = cancellationValue + } + + func wait() async -> Value { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let resolved = state.withLock { state -> Value? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("Mailbox waiter registered more than once.") + case .resolved(let value): + return value + } + } + if let resolved { + continuation.resume(returning: resolved) + } + } + } onCancel: { + _ = self.resolve(self.cancellationValue) + } + } + + @discardableResult + func resolve(_ value: Value) -> Bool { + let resolution = state.withLock { + state -> (Bool, CheckedContinuation?) in + switch state { + case .pending(let continuation): + state = .resolved(value) + return (true, continuation) + case .resolved: + return (false, nil) + } + } + resolution.1?.resume(returning: value) + return resolution.0 + } +} diff --git a/Sources/CodexAppServerKit/LoginRegistry.swift b/Sources/CodexAppServerKit/LoginRegistry.swift new file mode 100644 index 00000000..c5524400 --- /dev/null +++ b/Sources/CodexAppServerKit/LoginRegistry.swift @@ -0,0 +1,446 @@ +import Foundation + +package actor LoginRegistry { + private var activeState: LoginState? + private var pendingReservation: UUID? + private let sleep: @Sendable (Duration) async throws -> Void + + package init( + sleep: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + } + ) { + self.sleep = sleep + } + + package func reserve( + readinessTimeout: Duration?, + cancel: + @escaping @Sendable (CodexLoginHandle.ID, Duration?) async throws + -> CodexLoginOutcome, + closeConnection: @escaping @Sendable () async -> Void + ) async throws -> LoginState { + if let activeState { + guard await activeState.isTerminal else { + throw CodexAppServerError.loginAlreadyInProgress + } + self.activeState = nil + } + guard pendingReservation == nil else { + throw CodexAppServerError.loginAlreadyInProgress + } + let reservation = UUID() + pendingReservation = reservation + let state = LoginState( + reservation: reservation, + readinessTimeout: readinessTimeout, + cancel: cancel, + closeConnection: closeConnection, + sleep: sleep, + didTerminate: { [weak self] reservation in + await self?.releaseTerminatedState(reservation: reservation) + } + ) + activeState = state + return state + } + + package func bind( + _ state: LoginState, + id: CodexLoginHandle.ID, + authenticationURL: URL + ) async throws -> CodexLoginHandle { + guard activeState === state, pendingReservation == state.reservation else { + throw CodexAppServerError.loginAlreadyInProgress + } + pendingReservation = nil + await state.bind(id: id, authenticationURL: authenticationURL) + return CodexLoginHandle(state: state, id: id, authenticationURL: authenticationURL) + } + + package func apply(_ completion: CodexLoginCompletion) async { + guard let state = activeState else { + return + } + await state.apply(completion) + } + + package func applyAccountUpdate( + _ update: AppServerNotificationDecoder.AccountUpdate + ) async { + guard let state = activeState else { + return + } + await state.applyAccountUpdate(update) + } + + package func finish(throwing error: CodexAppServerError) async { + guard let state = activeState else { + return + } + await state.finishConnection(throwing: error) + } + + package func abandon(_ state: LoginState) async { + guard activeState === state else { + return + } + pendingReservation = nil + activeState = nil + await state.abandon() + } + + private func releaseTerminatedState(reservation: UUID) async { + guard let state = activeState, + state.reservation == reservation, + await state.isTerminal else { + return + } + guard activeState === state else { + return + } + pendingReservation = nil + activeState = nil + } + +} + +package actor LoginState { + package let reservation: UUID + + private enum Phase { + case starting + case pending(id: CodexLoginHandle.ID, observedAuthMode: AccountUpdate.AuthMode?) + case successAwaitingAccount(id: CodexLoginHandle.ID) + case terminal(Result) + } + + private typealias AccountUpdate = AppServerNotificationDecoder.AccountUpdate + + private enum PreBindEvent: Sendable { + case completion(CodexLoginCompletion) + case accountUpdate(AppServerNotificationDecoder.AccountUpdate) + } + + private var phase: Phase = .starting + private var waiters: [UUID: CheckedContinuation, Never>] = [:] + private var readinessTask: Task? + private var cancelTask: Task, Never>? + private var preBindEvents: [PreBindEvent] = [] + private let readinessTimeout: Duration? + private let cancelOperation: + @Sendable (CodexLoginHandle.ID, Duration?) async throws + -> CodexLoginOutcome + private let closeConnectionOperation: @Sendable () async -> Void + private let sleep: @Sendable (Duration) async throws -> Void + private let didTerminate: @Sendable (UUID) async -> Void + + package init( + reservation: UUID, + readinessTimeout: Duration?, + cancel: + @escaping @Sendable (CodexLoginHandle.ID, Duration?) async throws + -> CodexLoginOutcome, + closeConnection: @escaping @Sendable () async -> Void, + sleep: @escaping @Sendable (Duration) async throws -> Void, + didTerminate: @escaping @Sendable (UUID) async -> Void + ) { + self.reservation = reservation + self.readinessTimeout = readinessTimeout + self.cancelOperation = cancel + self.closeConnectionOperation = closeConnection + self.sleep = sleep + self.didTerminate = didTerminate + } + + package var isTerminal: Bool { + if case .terminal = phase { true } else { false } + } + + package func bind(id: CodexLoginHandle.ID, authenticationURL _: URL) { + switch phase { + case .starting: + phase = .pending(id: id, observedAuthMode: nil) + case .terminal: + return + case .pending, .successAwaitingAccount: + preconditionFailure("A login start response can bind only once.") + } + let events = preBindEvents + preBindEvents.removeAll(keepingCapacity: false) + for event in events { + switch event { + case .completion(let completion): + apply(completion) + case .accountUpdate(let update): + applyAccountUpdate(update) + } + } + } + + package func apply(_ completion: CodexLoginCompletion) { + switch phase { + case .starting: + bufferPreBindEvent(.completion(completion)) + case .pending(let id, let observedAuthMode): + guard let loginID = completion.loginID else { + resolve( + .success( + .failed( + message: "account/login/completed omitted loginId for the active login." + ))) + return + } + guard loginID == id else { + return + } + if completion.success { + if let observedAuthMode { + resolveReadiness(authMode: observedAuthMode) + } else { + phase = .successAwaitingAccount(id: id) + startReadinessDeadlineIfNeeded() + } + } else { + resolve(.success(.failed(message: completion.error))) + } + case .successAwaitingAccount, .terminal: + return + } + } + + package func applyAccountUpdate(_ update: AppServerNotificationDecoder.AccountUpdate) { + if case .starting = phase { + bufferPreBindEvent(.accountUpdate(update)) + return + } + guard let authMode = update.authMode else { + return + } + if case .pending(let id, _) = phase { + phase = .pending(id: id, observedAuthMode: authMode) + return + } + guard case .successAwaitingAccount = phase else { + return + } + resolveReadiness(authMode: authMode) + } + + private func resolveReadiness(authMode: AccountUpdate.AuthMode) { + guard authMode == .chatGPT else { + resolve( + .success( + .authenticationCommittedNeedsConnectionReconciliation( + .chatGPTAccountUnavailableAfterSuccess + ))) + return + } + resolve(.success(.succeeded)) + } + + package func finishConnection(throwing error: CodexAppServerError) { + switch phase { + case .successAwaitingAccount: + if case .malformedNotification(let malformed) = error, + malformed.method == "account/updated" + { + resolve( + .success( + .authenticationCommittedNeedsConnectionReconciliation( + .malformedAccountUpdateAfterSuccess(malformed) + ))) + return + } + let termination: CodexConnectionTermination = + if case .connectionTerminated(let value) = error { + value + } else { + .transportFailure( + .protocolViolation( + message: error.localizedDescription, + rawData: nil + )) + } + resolve( + .success( + .authenticationCommittedNeedsConnectionReconciliation( + .connectionTerminated(termination) + ))) + case .pending where cancelTask != nil: + resolve(.success(cancelOutcomeRequiringReconciliation(requestFailure: nil))) + case .starting, .pending: + resolve(.failure(error)) + case .terminal: + break + } + } + + package func abandon() { + switch phase { + case .starting: + resolve(.failure(.loginAlreadyInProgress)) + case .pending, .successAwaitingAccount, .terminal: + break + } + } + + package func result() async throws -> CodexLoginOutcome { + let token = UUID() + return try await withTaskCancellationHandler { + let result = await withCheckedContinuation { + (continuation: CheckedContinuation, Never>) in + if Task.isCancelled { + continuation.resume(returning: .failure(CancellationError())) + } else if case .terminal(let result) = phase { + continuation.resume(returning: result.mapError { $0 as Error }) + } else { + waiters[token] = continuation + } + } + return try result.get() + } onCancel: { + Task { await self.cancelWaiter(token) } + } + } + + package func cancel(acknowledgementTimeout: Duration?) async throws -> CodexLoginOutcome { + switch phase { + case .terminal(let result): + return try result.get() + case .pending(let id, _), .successAwaitingAccount(let id): + let task: Task, Never> + if let cancelTask { + task = cancelTask + } else { + task = Task { [cancelOperation] in + do { + return .success(try await cancelOperation(id, acknowledgementTimeout)) + } catch { + return .failure(error) + } + } + cancelTask = task + } + let outcome: CodexLoginOutcome + do { + outcome = try await task.value.get() + } catch { + switch phase { + case .pending, .successAwaitingAccount: + let requestFailure: CodexRequestFailure? + if let appServerError = error as? CodexAppServerError, + case .request(let failure) = appServerError + { + requestFailure = failure + } else { + requestFailure = nil + } + let reconciliation = cancelOutcomeRequiringReconciliation( + requestFailure: requestFailure + ) + resolve(.success(reconciliation)) + return reconciliation + case .terminal(let result): + return try result.get() + case .starting: + throw error + } + } + switch phase { + case .successAwaitingAccount: + return try await result() + case .terminal(let result): + return try result.get() + case .pending: + resolve(.success(outcome)) + return outcome + case .starting: + preconditionFailure("A started login cannot return to its starting phase.") + } + case .starting: + throw CodexAppServerError.loginAlreadyInProgress + } + } + + package func closeConnection() async { + await closeConnectionOperation() + } + + private func cancelWaiter(_ token: UUID) { + waiters.removeValue(forKey: token)?.resume(returning: .failure(CancellationError())) + } + + private func cancelOutcomeRequiringReconciliation( + requestFailure: CodexRequestFailure? + ) -> CodexLoginOutcome { + .authenticationCommittedNeedsConnectionReconciliation( + .cancelOutcomeUnknown(requestFailure) + ) + } + + private func bufferPreBindEvent(_ event: PreBindEvent) { + guard preBindEvents.count < Self.preBindEventLimit else { + resolve( + .failure( + .malformedNotification( + .init( + method: "account/login pre-bind events", + message: "Exceeded the bounded pre-bind event capacity.", + rawData: nil + )))) + return + } + preBindEvents.append(event) + } + + private func startReadinessDeadlineIfNeeded() { + guard let readinessTimeout else { + return + } + let sleep = self.sleep + readinessTask = Task { [weak self, sleep] in + do { + try await sleep(readinessTimeout) + } catch { + return + } + await self?.readinessDeadlineExceeded(readinessTimeout) + } + } + + private func readinessDeadlineExceeded(_ timeout: Duration) { + guard case .successAwaitingAccount = phase else { + return + } + resolve( + .success( + .authenticationCommittedNeedsConnectionReconciliation( + .accountReadinessDeadlineExceeded(timeout) + ))) + } + + private func resolve(_ result: Result) { + if case .terminal = phase { + return + } + readinessTask?.cancel() + readinessTask = nil + cancelTask?.cancel() + cancelTask = nil + preBindEvents.removeAll(keepingCapacity: false) + phase = .terminal(result) + let reservation = reservation + let didTerminate = didTerminate + Task { + await didTerminate(reservation) + } + let continuations = waiters.values + waiters.removeAll() + for continuation in continuations { + continuation.resume(returning: result.mapError { $0 as Error }) + } + } + + private static let preBindEventLimit = 16 +} diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md new file mode 100644 index 00000000..6a7867e9 --- /dev/null +++ b/Sources/CodexAppServerKit/README.md @@ -0,0 +1,576 @@ +# CodexAppServerKit + +CodexAppServerKit is a Swift library for working with a local +`codex app-server` process from macOS apps and tools. + +The package hides JSON-RPC framing and app-server DTOs behind Swift domain +types. Callers work with an app-server container, sessions, prompts, +typed turn outcomes, transcript items, models, accounts, and login handles. + +## Container + +Create one `CodexAppServer` for the lifetime of the app-server connection: + +```swift +import CodexAppServerKit + +let appServer = try await CodexAppServer() +let thread = try await appServer.startThread(in: workspaceURL) + +let outcome = try await thread.respond(to: "Review this workspace.") +if case .completed(let response) = outcome { + print(response.transcript.finalAnswer ?? "") +} + +await appServer.close() +``` + +`CodexAppServer()` uses the local `codex` executable over stdio. It performs +`initialize` / `initialized`, manages the process transport, routes +notifications, retries app-server overload responses, and preserves schema-new +notifications as unknown domain events. + +Use the root-bound connection sequence for diagnostics and the single typed +termination reason: + +```swift +let connectionEvents = await appServer.connectionEvents() +for await event in connectionEvents { + switch event { + case .warning(let diagnostic): + print(diagnostic.message) + case .retrying(let retry): + print("Retrying \(retry.method), attempt \(retry.attempt)") + case .deprecation(let notice): + print(notice.summary) + case .unknown(let notification): + print("Future notification: \(notification.method)") + case .terminated(let reason): + print("Connection ended: \(reason)") + } +} +``` + +The sequence does not retain the app-server connection. Each subscriber keeps +the newest 32 pending diagnostics; the terminal event supersedes pending +diagnostics and is the only event replayed to a late subscriber. + +## Configuration + +`CodexAppServer.Configuration` owns the container identity and local-process +runtime settings. The default local process resolves Codex home from +`CODEX_HOME`, then `HOME/.codex` on macOS command-line runs, then Application +Support for container-style environments. Pass `localProcess.codexHomeURL` when +an app wants an isolated runtime directory. + +```swift +let configuration = CodexAppServer.Configuration( + localProcess: .init( + codexHomeURL: appSupportURL.appendingPathComponent("Codex", isDirectory: true) + ) +) +let appServer = try await CodexAppServer(configuration: configuration) +``` + +Install a typed server-request handler when the host needs to answer approvals, +user-input prompts, dynamic tool calls, or provider requests. Delegate request +kinds the host does not override to the built-in policy: + +```swift +let configuration = CodexAppServer.Configuration( + serverRequestHandler: { request in + switch request { + case .commandExecutionApproval: + return .approval(.accept) + case .userInput(let prompt): + return .userInput(.init(answers: prompt.questions.reduce(into: [:]) { + $0[$1.id] = .init(answers: []) + })) + default: + return try await CodexAppServer.Configuration + .defaultServerRequestHandler(request: request) + } + } +) +``` + +## Threads + +`CodexThread` is the long-lived session handle for a Codex conversation in a workspace. Use `respond` to wait for an exhaustive terminal outcome. + +```swift +let thread = try await appServer.startThread( + in: workspaceURL, + instructions: .init(developer: "Keep responses concise."), + options: .init(model: "gpt-5", approvalMode: .autoReview) +) + +let outcome = try await thread.respond { + "Run the checks." + "Focus on failing tests." +} +``` + +Thread management is exposed without requiring raw request DTOs: + +```swift +let snapshot = try await thread.read(includeTurns: true) +try await thread.rename(to: "Release review") +try await thread.compact() +try await thread.archive() +let restored = try await thread.unarchive() +try await thread.delete() +``` + +## Package-Internal Streaming + +`streamResponse` and the derived event sequences are package-level implementation +details used by DataKit and package tests. Public consumers use `respond`, or a +`CodexReviewSession` returned from `startReview`. + +```swift +let stream = try await thread.streamResponse(to: "Summarize the changes.") + +for try await snapshot in stream { + render(snapshot.transcript.items) +} + +let response = try await stream.collect() +``` + +Codex also supports explicit cancellation for an in-flight response. App-server +has real `turn/steer` and `turn/interrupt` control paths, so +`CodexResponseStream` exposes them directly: + +```swift +let stream = try await thread.streamResponse(to: "Run the slow checks.") +try await stream.steer(with: "Prefer the smallest fix.") +try await stream.cancel() +``` + +Cancelling a task that awaits `stream.collect()` only stops that local +consumer. Explicit `stream.cancel()` owns the server-side interrupt. + +Use `steer(with:)` when new input should modify the current turn. Start a +follow-up from the reusable `CodexThread` handle; terminal response handles +release their connection lease and do not start another generation. + +It also exposes reasoning controls with domain values instead of raw strings: + +```swift +let outcome = try await thread.respond( + to: "Find the risky part of this change.", + options: .init( + effort: .high, + summary: .detailed, + personality: .pragmatic + ) +) + +if case .completed(let response) = outcome { + print(response.usage?.reasoningOutputTokens ?? 0) +} +``` + +Structured final answers can be constrained with a JSON schema: + +```swift +let outcome = try await thread.respond( + to: "Summarize the change as JSON.", + options: .init(outputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "summary": .object(["type": .string("string")]), + "risk": .object(["type": .string("string")]), + ]), + "required": .array([.string("summary"), .string("risk")]), + ])) +) +``` + +The package-internal thread handle also exposes async sequences for chat, +transcript updates, and log-style consumers. + +```swift +for try await message in thread.messages { + print(message.text) +} +``` + +```swift +for try await transcript in thread.transcriptUpdates { + render(transcript.items) +} +``` + +```swift +for try await entry in thread.logEntries { + switch entry { + case .reasoningDelta(let delta, _): + renderReasoningDelta(delta) + case .itemStarted(let item, _), .itemUpdated(let item, _), .itemCompleted(let item, _): + switch item.content { + case .message(let message): + renderMessage(message) + case .reasoning(let reasoning): + renderReasoning(summary: reasoning.summary, content: reasoning.content) + case .command(let command): + renderCommand(command.command, output: command.output) + case .toolCall(let tool): + renderToolCall(tool.name, result: tool.result, error: tool.error) + case .fileChange(let fileChange): + renderFileChange(fileChange.path, output: fileChange.output) + default: + break + } + case .messageDelta(let delta, _, _): + renderMessageDelta(delta.text) + default: + break + } +} +``` + +`CodexThread.events` is the full thread event stream. It includes turn +lifecycle, item lifecycle, message deltas, token usage, thread status, and +unknown notifications: + +```swift +for try await event in thread.events { + switch event { + case .reasoningDelta(let delta, _): + renderReasoningDelta(delta) + case .tokenUsageUpdated(let usage, _): + updateUsage(usage.totalTokens) + case .unknown(let raw): + logUnknownNotification(raw.method) + default: + break + } +} +``` + +The stream represents the thread's current generation, not an append-only +connection history. A generation is registered before a scoped request is +written and becomes current only after its response is accepted; attempts that +fail or cancel before an accepted response leave the previous generation +intact. Each subscriber has a +bounded 256-event channel. A slow subscriber receives an authoritative turn +snapshot plus the newest usage/status and bounded unknown diagnostics when its +incremental queue overflows. Terminal and `thread/closed` events are never +dropped, and cancelling one iterator synchronously removes only that +subscriber. + +This lets review clients build logs from +CodexAppServerKit domain events instead of parsing JSON-RPC notifications or +string logs directly. + +Known `CodexThreadItem` values keep their high-level `content` projection and +the original `rawPayload`. Use the raw payload when a product needs +full-fidelity rendering for app-server fields that the current Kit version does +not yet model directly. + +## Reviews + +`review/start` is part of the app-server surface, so CodexAppServerKit exposes +it as a high-level `CodexAppServer` operation and as a lower-level thread +operation for callers that already own a thread. App-server does not expose a +separate review transport stream. A review session owns the source, active +review-thread, and turn identities and exposes one typed terminal outcome. + +```swift +let review = try await appServer.startReview( + in: workspaceURL, + target: .baseBranch("main"), + options: .init(model: "gpt-5") +) + +let outcome = try await review.collect() +if case .completed(let response) = outcome { + print(response.transcript.reviewOutputText ?? "") +} +``` + +Use `CodexThread.startReview` when a thread owner is already explicit: + +```swift +let thread = try await appServer.startThread(in: workspaceURL) +let review = try await thread.startReview(target: .uncommittedChanges) +``` + +Review targets are Swift domain values: + +```swift +try await appServer.startReview(in: workspaceURL, target: .uncommittedChanges) +try await appServer.startReview(in: workspaceURL, target: .commit(sha: sha, title: title)) +try await appServer.startReview(in: workspaceURL, target: .custom(instructions: instructions)) +``` + +Review output is exposed as `CodexTranscript.reviewOutputText` from the +`exitedReviewMode` item. Incremental review transport sequences stay inside the +package; apps that render live thread content use CodexDataKit's context-owned +chat observation instead of building a second review model graph. + +`terminalOutcomeIfKnown()` is a nonwaiting read of the same review-generation +state used by `collect()`. It returns `nil` while the turn is live, never sends a +request, and surfaces a committed connection termination as an error. This is +primarily useful to arbitrate a caller-cancellation race without starting a +second collector. + +`CodexReviewSession` also owns the app-server lifecycle identity for a review. +Use `sourceThreadID`, `activeTurnThreadID`, `associatedThreadIDs`, and +`cleanupThreadIDs` when a host app needs to track source, detached review, and +cleanup ownership without keeping its own app-server dictionaries. + +```swift +let identity = review.identity +persist(identity) + +let restored = try await appServer.resumeReview(identity) +let cancellation = try await restored.cancel() +noteActiveTurnThread(cancellation.threadID) +``` + +`CodexReviewIdentity` is a `Codable` Swift value containing only CodexKit +identity: source thread, review turn, optional detached review thread, and +active review thread model when known. It is intended for persisted app-server +review runs and does not depend on any higher-level review domain model. + +`CodexAppServer` also owns app-server review restart and cleanup lifecycle +state. A host that needs to interrupt and restart a review can prepare a +transient token, restart from it, then perform cleanup without tracking +detached review thread IDs itself: + +```swift +let token = try await appServer.prepareReviewRestart(identity) +let restarted = try await appServer.restartPreparedReview( + token, + target: .baseBranch("main"), + delivery: .detached +) +let cleanup = await appServer.cleanupReview(restarted.identity) +if cleanup.succeeded == false { + persistForLaterCleanup(cleanup.attemptedThreadIDs) +} +``` + +Cleanup returns every attempted thread ID and its ordered failures. When any +deletion fails, identities retained by restart preparation stay registered so +the same app-server generation can retry without losing cleanup ownership. + +Preparation and restart are owned by one process-local coordinator. Concurrent +restart calls with the same token, target, delivery, and thread options join one +shared operation; a cancelled waiter does not cancel that operation. A token +allows at most two restart invocations, and the deprecated rollback request is +sent successfully at most once. + +When a host stops a run before consuming its prepared token, invalidate it and +take ownership of every identity retained for that source thread: + +```swift +let retained = await appServer.discardPreparedReviewRestart(token) +persistForLaterCleanup(retained) +``` + +Runtime owners use `discardAllPreparedReviewRestarts()` before closing the +app-server connection. It waits for in-flight preparation and restart work, +interrupts any replacement session that arrives after invalidation, and returns +an ordered identity list for each source thread. This is a terminal close of +restart preparation for that `CodexAppServer` instance. Neither discard +operation deletes review threads; the caller decides when durable ownership +permits final cleanup. + +## Responses + +`CodexThread.respond` and `CodexReviewSession.collect` return +`CodexTurnOutcome`: `.completed`, `.interrupted`, `.failed`, or +`.invalidTerminalStatus`. Every case carries a `CodexResponse`; failed turns +also carry a non-optional `CodexTurnError`. Caller cancellation throws +`CancellationError` and is not a terminal outcome. + +`CodexResponse` carries transcript, token usage, timing, and `turnID`. + +Final answers are derived from assistant messages whose phase is +`.finalAnswer`. If no final-answer phase is present, the last normal assistant +message is used as a fallback. + +## Prompts + +`CodexPrompt` accepts text and structured parts: + +```swift +let prompt: CodexPrompt = .init(parts: [ + .text("Explain this screenshot."), + .localImage(screenshotURL), + .mention(name: "repo", path: workspaceURL), +]) +``` + +String literals are supported for simple prompts: + +```swift +try await thread.respond(to: "What changed?") +``` + +For dynamic prompts, use the result-builder initializer or the builder overloads +on `respond` and the package-level `streamResponse`: + +```swift +let response = try await thread.respond { + "Explain this screenshot." + CodexPrompt.Part.localImage(screenshotURL) + if includeRepository { + CodexPrompt.Part.mention(name: "repo", path: workspaceURL) + } +} +``` + +## Models, Account, And Login + +```swift +let models = try await appServer.models() +let account = try await appServer.account(refreshToken: true) +let configuration = try await appServer.configuration() +let rateLimits = try await appServer.rateLimits() +``` + +Update configuration through a patch so `nil` can mean "clear this setting" +without making every field optional update state visible in call sites: + +```swift +var patch = CodexConfigurationPatch() +patch.setReviewModel("gpt-5-codex-review") +patch.setReasoningEffort(.high) +patch.setServiceTier(nil) +try await appServer.updateConfiguration(patch) +``` + +ChatGPT browser login returns a typed handle: + +```swift +let handle = try await appServer.loginChatGPT() +openInBrowser(handle.authenticationURL) +let outcome = try await handle.result() +``` + +API-key login is an immediate credential replacement owned by the app-server: + +```swift +func configureAuthentication( + on appServer: CodexAppServer, + apiKey: String +) async throws { + try await appServer.login(apiKey: apiKey) +} +``` + +A successful return means the app-server stored and reloaded the key in its +configured Codex home. Login does not make a remote API request, so it does not +prove that the key will be accepted by the API. Empty keys and keys with leading +or trailing whitespace fail before a request is sent. + +Caller cancellation before write acceptance sends nothing. Once the request is +written, cancellation is deferred until its correlated response is known. A +connection loss, deadline, or malformed response after write acceptance throws +`CodexAppServerError.authenticationOutcomeUnknown`; reconcile the authoritative +account state before retrying. + +## Testing + +Use `CodexAppServerKitTesting` to exercise `CodexAppServer` without launching a +real `codex app-server` process. The test runtime uses an in-memory transport, +enqueues the startup `initialize` response, records requests, and lets tests +emit server notifications explicitly. + +```swift +import CodexAppServerKit +import CodexAppServerKitTesting +import Foundation +import Testing + +@Test func readsConfiguration() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let layer = try CodexAppServerTestConfigurationLayerMetadata( + source: .user( + file: URL(fileURLWithPath: "/tmp/codex/config.toml"), + profile: nil + ), + version: "test-config-v1" + ) + let fixture = try CodexAppServerTestConfigurationReadResult( + configuration: .init(model: "gpt-5-codex"), + origins: ["model": layer], + layers: [try .init( + metadata: layer, + configuration: .object(["model": .string("gpt-5-codex")]) + )] + ) + try await runtime.transport.enqueueConfiguration(fixture) + + let configuration = try await runtime.server.configuration() + + #expect(configuration == fixture.configuration) + #expect(await runtime.transport.recordedRequests(for: .configurationRead).count == 1) + await runtime.close() +} +``` + +Tests can hold a typed operation with +`CodexAppServerTestGate` and release it explicitly. This avoids depending on +sleep duration or repeated `Task.yield()` calls. + +```swift +let gate = CodexAppServerTestGate() +await runtime.transport.holdNext(.configurationRead, gate: gate) +try await runtime.transport.enqueueConfiguration(fixture) + +let readTask = Task { + try await runtime.server.configuration() +} + +await runtime.transport.waitForRequest(.configurationRead) +await gate.open() +_ = try await readTask.value +``` + +## Boundary + +Public users should not need to import or call JSON-RPC or `AppServerAPI` +request DTOs. Those remain package-level implementation details. + +The public boundary is: + +- `CodexAppServer` +- `CodexThreadID` +- `CodexTurnID` +- `CodexThread` +- `CodexReviewTarget` +- `CodexReviewSession` +- `CodexReviewIdentity` +- `CodexReviewCleanupFailure` +- `CodexReviewCleanupResult` +- `CodexReviewRestartToken` +- `CodexTurnSnapshot` +- `CodexTurnStatus` +- `CodexThreadStatus` +- `CodexTurnOutcome` +- `CodexFailedTurn` +- `CodexTurnError` +- `CodexErrorInfo` +- `CodexResponse` +- `CodexAppServerError` +- `CodexRequestFailure` +- `CodexServerError` +- `CodexGenerationOptions` +- `CodexPrompt` +- `CodexTranscript` +- `CodexThreadItem` +- `CodexModel` +- `CodexAccount` +- `CodexAccountEvent` +- `CodexAPIKeyValidationFailure` +- `CodexAuthenticationOutcomeUnknownReason` +- `CodexLoginHandle` + +Unknown notifications and unknown item kinds are preserved so clients can keep +running when app-server adds new schema. diff --git a/Sources/CodexAppServerKit/RequestSerializer.swift b/Sources/CodexAppServerKit/RequestSerializer.swift new file mode 100644 index 00000000..f6d72437 --- /dev/null +++ b/Sources/CodexAppServerKit/RequestSerializer.swift @@ -0,0 +1,567 @@ +import Foundation +import Synchronization + +package final class RequestOperationState: Sendable { + package enum PostWriteCallerCancellationPolicy: Equatable, Sendable { + case performCleanup + case returnResponse + } + + package enum ResponseDisposition: Sendable { + case returnResponse + case performCleanup(RequestOperationAbandonment) + } + + package enum DeadlineDisposition: Sendable { + case ignored + case awaitPreWriteExit + case closeConnection + } + + private enum Phase: Sendable { + case preWrite + case written + case retryWaiting + case retryReady + case responseBound + case cleanupComplete + case returned + } + + private struct State: Sendable { + var phase: Phase = .preWrite + var abandonment: RequestOperationAbandonment? + var abandonmentWaiters: [ + UUID: CheckedContinuation + ] = [:] + } + + private let state = Mutex(State()) + + package init() {} + + package func requestCancellation() { + let waiters = state.withLock { state -> [ + CheckedContinuation + ] in + guard state.phase != .returned, state.phase != .cleanupComplete, + state.abandonment == nil else { + return [] + } + state.abandonment = .callerCancellation + defer { state.abandonmentWaiters.removeAll(keepingCapacity: false) } + return Array(state.abandonmentWaiters.values) + } + for waiter in waiters { + waiter.resume(returning: .callerCancellation) + } + } + + package func requestDeadline() -> DeadlineDisposition { + let result = state.withLock { state -> ( + DeadlineDisposition, + [CheckedContinuation] + ) in + guard state.phase != .returned, state.phase != .cleanupComplete, + state.abandonment == nil else { + return (.ignored, []) + } + state.abandonment = .deadline + let disposition: DeadlineDisposition = switch state.phase { + case .preWrite: + .awaitPreWriteExit + case .written: + .closeConnection + case .retryWaiting, .retryReady: + .awaitPreWriteExit + case .responseBound: + .awaitPreWriteExit + case .cleanupComplete, .returned: + .ignored + } + defer { state.abandonmentWaiters.removeAll(keepingCapacity: false) } + return (disposition, Array(state.abandonmentWaiters.values)) + } + for waiter in result.1 { + waiter.resume(returning: .deadline) + } + return result.0 + } + + package func acceptWrite() throws { + let abandonment = state.withLock { state -> RequestOperationAbandonment? in + switch state.phase { + case .preWrite, .retryReady: + if let abandonment = state.abandonment { + return abandonment + } + state.phase = .written + return nil + case .written, .retryWaiting, .responseBound, .cleanupComplete, .returned: + preconditionFailure("A request cannot write after binding its response.") + } + } + if let abandonment { + throw abandonment + } + } + + package func beginRetryWait() throws { + let abandonment = state.withLock { state -> RequestOperationAbandonment? in + precondition(state.phase == .written, "A retry wait requires a correlated response.") + state.phase = .retryWaiting + return state.abandonment + } + if let abandonment { + throw abandonment + } + } + + package func finishRetryWait() throws { + let abandonment = state.withLock { state -> RequestOperationAbandonment? in + precondition(state.phase == .retryWaiting, "Only a waiting retry can become ready.") + if let abandonment = state.abandonment { + return abandonment + } + state.phase = .retryReady + return nil + } + if let abandonment { + throw abandonment + } + } + + package func waitForAbandonment() async -> RequestOperationAbandonment? { + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let abandonment = state.withLock { state -> RequestOperationAbandonment? in + if let abandonment = state.abandonment { + return abandonment + } + state.abandonmentWaiters[waiterID] = continuation + return nil + } + if let abandonment { + continuation.resume(returning: abandonment) + } + } + } onCancel: { + cancelAbandonmentWaiter(waiterID) + } + } + + private func cancelAbandonmentWaiter(_ waiterID: UUID) { + let waiter = state.withLock { state in + state.abandonmentWaiters.removeValue(forKey: waiterID) + } + waiter?.resume(returning: nil) + } + + package func markResponseBound() { + state.withLock { state in + precondition(state.phase == .written, "A response can only bind after write acceptance.") + state.phase = .responseBound + } + } + + package func resolveResponse( + postWriteCallerCancellationPolicy: PostWriteCallerCancellationPolicy + ) -> ResponseDisposition { + state.withLock { state in + precondition(state.phase == .responseBound, "A request response must be bound before resolution.") + if let abandonment = state.abandonment { + if abandonment == .callerCancellation, + postWriteCallerCancellationPolicy == .returnResponse { + state.phase = .returned + return .returnResponse + } + return .performCleanup(abandonment) + } + state.phase = .returned + return .returnResponse + } + } + + package func markCleanupComplete() { + state.withLock { state in + precondition(state.phase == .responseBound, "Cancellation cleanup requires a bound response.") + precondition(state.abandonment != nil, "Request cleanup requires operation abandonment.") + state.phase = .cleanupComplete + } + } + + package func preWriteCancellationShouldWin() -> Bool { + state.withLock { state in + (state.phase == .preWrite || state.phase == .retryReady) + && state.abandonment == .callerCancellation + } + } +} + +package enum RequestOperationAbandonment: Error, Equatable, Sendable { + case callerCancellation + case deadline +} + +package actor RequestSerializer { + private struct Lane { + var activeTokenID: UUID + var waiters: [RequestLaneWaiter] + } + + package struct LaneToken: Sendable { + var scope: AppServerAPI.RequestScope + var id: UUID + } + + @TaskLocal private static var currentCleanupLaneToken: LaneToken? + + private var lanes: [AppServerAPI.RequestScope: Lane] = [:] + private var queueCountWaiters: [AppServerAPI.RequestScope: [RequestQueueCountWaiter]] = [:] + + package init() {} + + package func run( + scope: AppServerAPI.RequestScope?, + operation: @Sendable (LaneToken?) async throws -> Output + ) async throws -> Output { + guard let scope else { + try Task.checkCancellation() + return try await operation(nil) + } + if let cleanupToken = Self.currentCleanupLaneToken, + cleanupToken.scope == scope, + lanes[scope]?.activeTokenID == cleanupToken.id { + return try await operation(cleanupToken) + } + + let token = try await enter(scope: scope) + do { + try Task.checkCancellation() + let output = try await operation(token) + leave(token) + return output + } catch { + leave(token) + throw error + } + } + + package func runCleanup( + using token: LaneToken?, + operation: @Sendable () async throws -> Output + ) async throws -> Output { + guard let token else { + return try await operation() + } + precondition( + lanes[token.scope]?.activeTokenID == token.id, + "Request cleanup requires its active request lane token." + ) + return try await Self.$currentCleanupLaneToken.withValue(token) { + try await operation() + } + } + + package func laneCountForTesting() -> Int { + lanes.count + } + + package func queuedWaiterCountForTesting( + scope: AppServerAPI.RequestScope + ) -> Int { + queuedWaiterCount(scope: scope) + } + + package func waitForQueuedWaiterCountForTesting( + scope: AppServerAPI.RequestScope, + atLeast minimumCount: Int + ) async throws { + precondition(minimumCount >= 0, "A request queue count cannot be negative.") + guard queuedWaiterCount(scope: scope) < minimumCount else { + return + } + let waiter = RequestQueueCountWaiter(minimumCount: minimumCount) + queueCountWaiters[scope, default: []].append(waiter) + let satisfied = await waiter.wait() + guard satisfied, Task.isCancelled == false else { + removeQueueCountWaiter(waiter.id, scope: scope) + throw CancellationError() + } + } + + private func enter(scope: AppServerAPI.RequestScope) async throws -> LaneToken { + try Task.checkCancellation() + guard lanes[scope] != nil else { + let token = LaneToken(scope: scope, id: UUID()) + lanes[scope] = Lane(activeTokenID: token.id, waiters: []) + return token + } + + let waiter = RequestLaneWaiter() + lanes[scope]?.waiters.append(waiter) + resumeSatisfiedQueueCountWaiters(scope: scope) + let acquired = await waiter.wait() + guard acquired else { + removeWaiter(waiter.id, scope: scope) + throw CancellationError() + } + + let token = LaneToken(scope: scope, id: waiter.id) + if Task.isCancelled { + leave(token) + throw CancellationError() + } + return token + } + + private func leave(_ token: LaneToken) { + guard var lane = lanes[token.scope] else { + preconditionFailure("Request lane disappeared while occupied.") + } + precondition( + lane.activeTokenID == token.id, + "Only the active request operation can release its lane." + ) + + while lane.waiters.isEmpty == false { + let waiter = lane.waiters.removeFirst() + if waiter.acquire() { + lane.activeTokenID = waiter.id + lanes[token.scope] = lane + resumeSatisfiedQueueCountWaiters(scope: token.scope) + return + } + } + lanes.removeValue(forKey: token.scope) + resumeSatisfiedQueueCountWaiters(scope: token.scope) + } + + private func removeWaiter(_ waiterID: UUID, scope: AppServerAPI.RequestScope) { + guard var lane = lanes[scope] else { + return + } + lane.waiters.removeAll { $0.id == waiterID } + lanes[scope] = lane + resumeSatisfiedQueueCountWaiters(scope: scope) + } + + private func queuedWaiterCount(scope: AppServerAPI.RequestScope) -> Int { + lanes[scope]?.waiters.filter(\.isPending).count ?? 0 + } + + private func resumeSatisfiedQueueCountWaiters(scope: AppServerAPI.RequestScope) { + guard let waiters = queueCountWaiters[scope] else { + return + } + let count = queuedWaiterCount(scope: scope) + var remaining: [RequestQueueCountWaiter] = [] + for waiter in waiters { + if count >= waiter.minimumCount { + waiter.satisfy() + } else if waiter.isPending { + remaining.append(waiter) + } + } + if remaining.isEmpty { + queueCountWaiters.removeValue(forKey: scope) + } else { + queueCountWaiters[scope] = remaining + } + } + + private func removeQueueCountWaiter(_ waiterID: UUID, scope: AppServerAPI.RequestScope) { + guard var waiters = queueCountWaiters[scope] else { + return + } + waiters.removeAll { $0.id == waiterID } + if waiters.isEmpty { + queueCountWaiters.removeValue(forKey: scope) + } else { + queueCountWaiters[scope] = waiters + } + } +} + +private final class RequestQueueCountWaiter: Sendable { + private enum State: Sendable { + case idle + case waiting(CheckedContinuation) + case satisfied + case cancelled + } + + let id = UUID() + let minimumCount: Int + private let state = Mutex(State.idle) + + init(minimumCount: Int) { + self.minimumCount = minimumCount + } + + var isPending: Bool { + state.withLock { state in + switch state { + case .idle, .waiting: + true + case .satisfied, .cancelled: + false + } + } + } + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> Bool? in + switch state { + case .idle: + if Task.isCancelled { + state = .cancelled + return false + } + state = .waiting(continuation) + return nil + case .satisfied: + return true + case .cancelled: + return false + case .waiting: + preconditionFailure("A queue-count waiter can only be awaited once.") + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + cancel() + } + } + + func satisfy() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .satisfied + return nil + case .waiting(let continuation): + state = .satisfied + return continuation + case .satisfied, .cancelled: + return nil + } + } + continuation?.resume(returning: true) + } + + private func cancel() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .cancelled + return nil + case .waiting(let continuation): + state = .cancelled + return continuation + case .satisfied, .cancelled: + return nil + } + } + continuation?.resume(returning: false) + } +} + +private final class RequestLaneWaiter: Sendable { + private enum State: Sendable { + case idle + case waiting(CheckedContinuation) + case acquired + case cancelled + } + + let id = UUID() + private let state = Mutex(State.idle) + + var isPending: Bool { + state.withLock { state in + switch state { + case .idle, .waiting: + true + case .acquired, .cancelled: + false + } + } + } + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> Bool? in + switch state { + case .idle: + if Task.isCancelled { + state = .cancelled + return false + } + state = .waiting(continuation) + return nil + case .acquired: + return true + case .cancelled: + return false + case .waiting: + preconditionFailure("A request lane waiter can only be awaited once.") + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + cancel() + } + } + + func acquire() -> Bool { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .acquired + return nil + case .waiting(let continuation): + state = .acquired + return continuation + case .cancelled: + return nil + case .acquired: + preconditionFailure("A request lane waiter can only acquire once.") + } + } + continuation?.resume(returning: true) + return state.withLock { state in + if case .acquired = state { + true + } else { + false + } + } + } + + private func cancel() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .cancelled + return nil + case .waiting(let continuation): + state = .cancelled + return continuation + case .acquired, .cancelled: + return nil + } + } + continuation?.resume(returning: false) + } +} diff --git a/Sources/CodexAppServerKit/ReviewRestartCoordinator.swift b/Sources/CodexAppServerKit/ReviewRestartCoordinator.swift new file mode 100644 index 00000000..34cdc808 --- /dev/null +++ b/Sources/CodexAppServerKit/ReviewRestartCoordinator.swift @@ -0,0 +1,1157 @@ +import Foundation +import OSLog +import Synchronization + +private let reviewRestartLogger = Logger( + subsystem: "CodexAppServerKit", + category: "review-restart" +) + +private func sameReviewLifecycleIdentity( + _ lhs: CodexReviewIdentity, + _ rhs: CodexReviewIdentity +) -> Bool { + lhs.sourceThreadID == rhs.sourceThreadID + && lhs.turnID == rhs.turnID + && lhs.reviewThreadID == rhs.reviewThreadID +} + +package final class ReviewRestartIdentityAccumulator: Sendable { + private let identities = Mutex<[CodexReviewIdentity]>([]) + + package init() {} + + package func record(_ identity: CodexReviewIdentity) { + identities.withLock { identities in + if identities.contains(where: { + sameReviewLifecycleIdentity($0, identity) + }) == false { + identities.append(identity) + } + } + } + + fileprivate func snapshot() -> [CodexReviewIdentity] { + identities.withLock { $0 } + } +} + +package actor ReviewRestartCoordinator { + package struct Context: Sendable { + package let token: CodexReviewRestartToken + package let interruptedIdentity: CodexReviewIdentity + package var rollbackThreadID: CodexThreadID + package var rollbackModel: String? + package var rollbackCompleted: Bool + package var restartAttemptsUsed: Int + + fileprivate init(token: CodexReviewRestartToken) { + self.token = token + self.interruptedIdentity = token.interruptedIdentity + self.rollbackThreadID = token.interruptedIdentity.activeTurnThreadID + self.rollbackModel = token.interruptedIdentity.model + self.rollbackCompleted = false + self.restartAttemptsUsed = 0 + } + } + + package struct Preparation: Sendable { + package var rollbackThreadID: CodexThreadID + package var rollbackModel: String? + + package init(rollbackThreadID: CodexThreadID, rollbackModel: String?) { + self.rollbackThreadID = rollbackThreadID + self.rollbackModel = rollbackModel + } + } + + package struct PreparationOperations: Sendable { + fileprivate let execute: @Sendable ( + ReviewRestartIdentityAccumulator + ) async throws -> Preparation + + package init( + execute: @escaping @Sendable ( + ReviewRestartIdentityAccumulator + ) async throws -> Preparation + ) { + self.execute = execute + } + } + + package struct RestartInvocationSignature: Equatable, Sendable { + package var target: CodexReviewTarget + package var delivery: CodexReviewDelivery + package var threadOptions: CodexThread.ResumeOptions + + package init( + target: CodexReviewTarget, + delivery: CodexReviewDelivery, + threadOptions: CodexThread.ResumeOptions + ) { + self.target = target + self.delivery = delivery + self.threadOptions = threadOptions + } + } + + package struct RestartOperations: Sendable { + fileprivate let loadRollbackThread: @Sendable (Context) async throws -> CodexThread + fileprivate let rollback: @Sendable (CodexThread) async throws -> Void + fileprivate let loadSourceThread: @Sendable (Context) async throws -> CodexThread + fileprivate let startReview: @Sendable ( + CodexThread, + ReviewRestartIdentityAccumulator + ) async throws -> CodexReviewSession + fileprivate let cleanupLateSession: @Sendable (CodexReviewSession) async throws -> Void + + package init( + loadRollbackThread: @escaping @Sendable (Context) async throws -> CodexThread, + rollback: @escaping @Sendable (CodexThread) async throws -> Void, + loadSourceThread: @escaping @Sendable (Context) async throws -> CodexThread, + startReview: @escaping @Sendable ( + CodexThread, + ReviewRestartIdentityAccumulator + ) async throws -> CodexReviewSession, + cleanupLateSession: @escaping @Sendable (CodexReviewSession) async throws -> Void + ) { + self.loadRollbackThread = loadRollbackThread + self.rollback = rollback + self.loadSourceThread = loadSourceThread + self.startReview = startReview + self.cleanupLateSession = cleanupLateSession + } + } + + package enum State: Sendable { + case preparing(Context, completion: PreparationCompletion) + case prepared(Context) + case restarting( + Context, + signature: RestartInvocationSignature, + completion: RestartCompletion + ) + case invalidating(Context, completion: InvalidationCompletion) + } + + private static let maximumRestartAttempts = 2 + + private var statesByTokenID: [CodexReviewRestartToken.ID: State] = [:] + private var retainedIdentityRecordsBySourceThreadID: [ + CodexThreadID: [RetainedIdentityRecord] + ] = [:] + private var acceptsNewWork = true + + package init() {} + + package func prepare( + _ identity: CodexReviewIdentity, + operations: PreparationOperations + ) async throws -> CodexReviewRestartToken { + try Task.checkCancellation() + guard acceptsNewWork else { + throw CodexAppServerError.reviewRestartUnavailable("coordinator-closed") + } + if let existing = statesByTokenID.values.first(where: { + Self.context(from: $0).interruptedIdentity.sourceThreadID + == identity.sourceThreadID + }) { + throw CodexAppServerError.reviewRestartUnavailable( + Self.context(from: existing).token.id + ) + } + + let token = CodexReviewRestartToken( + id: UUID().uuidString, + interruptedIdentity: identity + ) + let context = Context(token: token) + let invalidation = InvalidationSignal() + let accumulator = ReviewRestartIdentityAccumulator() + let operationID = UUID() + let task = Task { + do { + return .succeeded( + try await operations.execute(accumulator), + retainedIdentities: accumulator.snapshot() + ) + } catch { + return .failed( + error, + retainedIdentities: accumulator.snapshot() + ) + } + } + let completion = PreparationCompletion( + id: operationID, + invalidation: invalidation, + task: task + ) + statesByTokenID[token.id] = .preparing(context, completion: completion) + + let outcome = await withTaskCancellationHandler { + await task.value + } onCancel: { + invalidation.request() + task.cancel() + } + return try finishPreparation( + tokenID: token.id, + completion: completion, + outcome: outcome, + callerWasCancelled: Task.isCancelled + ) + } + + package func restart( + _ token: CodexReviewRestartToken, + signature: RestartInvocationSignature, + operations: RestartOperations + ) async throws -> CodexReviewSession { + try Task.checkCancellation() + guard acceptsNewWork else { + throw CodexAppServerError.reviewRestartUnavailable(token.id) + } + + let completion: RestartCompletion + switch statesByTokenID[token.id] { + case .prepared(var context): + try requireMatchingToken(token, context: context) + guard context.restartAttemptsUsed < Self.maximumRestartAttempts else { + statesByTokenID.removeValue(forKey: token.id) + throw CodexAppServerError.reviewRestartUnavailable(token.id) + } + context.restartAttemptsUsed += 1 + let operationID = UUID() + let invalidation = InvalidationSignal() + let resultCell = SharedResultCell() + let task = Task { [self] in + let outcome = await executeRestart( + operationID: operationID, + context: context, + operations: operations + ) + resultCell.resolve(outcome) + return outcome + } + completion = RestartCompletion( + id: operationID, + invalidation: invalidation, + task: task, + resultCell: resultCell, + cleanupLateSession: operations.cleanupLateSession + ) + statesByTokenID[token.id] = .restarting( + context, + signature: signature, + completion: completion + ) + case .restarting(let context, let existingSignature, let existingCompletion): + try requireMatchingToken(token, context: context) + guard existingSignature == signature, + existingCompletion.invalidation.isRequested == false else { + throw CodexAppServerError.reviewRestartUnavailable(token.id) + } + completion = existingCompletion + case .preparing(let context, _), .invalidating(let context, _): + try requireMatchingToken(token, context: context) + throw CodexAppServerError.reviewRestartUnavailable(token.id) + case nil: + throw CodexAppServerError.reviewRestartUnavailable(token.id) + } + + let outcome = try await completion.wait() + return try await finishRestart( + tokenID: token.id, + completion: completion, + outcome: outcome + ) + } + + package func invalidate( + _ token: CodexReviewRestartToken + ) async -> [CodexReviewIdentity] { + guard let state = statesByTokenID[token.id] else { + return takeRetainedIdentities(ownerToken: token) + } + let context = Self.context(from: state) + guard context.token == token else { + return [] + } + let completion = transitionToInvalidating( + tokenID: token.id, + state: state + ) + _ = await completion.task.value + return await finishInvalidation( + tokenID: token.id, + context: context, + completion: completion + ) + } + + package func invalidateAllAndWait() + async -> [CodexThreadID: [CodexReviewIdentity]] { + acceptsNewWork = false + let stateSnapshot = Array(statesByTokenID) + let invalidations = stateSnapshot.map { tokenID, state in + let context = Self.context(from: state) + let completion = transitionToInvalidating( + tokenID: tokenID, + state: state + ) + return (tokenID, context, completion) + } + + var result: [CodexThreadID: [CodexReviewIdentity]] = [:] + for (tokenID, context, completion) in invalidations { + _ = await completion.task.value + let identities = await finishInvalidation( + tokenID: tokenID, + context: context, + completion: completion + ) + Self.merge( + identities, + into: &result[context.interruptedIdentity.sourceThreadID, default: []] + ) + } + for sourceThreadID in Array(retainedIdentityRecordsBySourceThreadID.keys) { + Self.merge( + takeRetainedIdentities(sourceThreadID: sourceThreadID), + into: &result[sourceThreadID, default: []] + ) + } + return result.filter { $0.value.isEmpty == false } + } + + package func invalidateAndTakeRetainedIdentities( + sourceThreadID: CodexThreadID + ) async -> [CodexReviewIdentity] { + let stateSnapshot = Array(statesByTokenID) + let invalidations = stateSnapshot.compactMap { tokenID, state -> ( + CodexReviewRestartToken.ID, + Context, + InvalidationCompletion + )? in + let context = Self.context(from: state) + guard context.interruptedIdentity.sourceThreadID == sourceThreadID else { + return nil + } + return ( + tokenID, + context, + transitionToInvalidating(tokenID: tokenID, state: state) + ) + } + var identities: [CodexReviewIdentity] = [] + for (tokenID, context, completion) in invalidations { + _ = await completion.task.value + Self.merge( + await finishInvalidation( + tokenID: tokenID, + context: context, + completion: completion + ), + into: &identities + ) + } + Self.merge( + takeRetainedIdentities(sourceThreadID: sourceThreadID), + into: &identities + ) + return identities + } + + package func restoreRetainedIdentities( + _ identities: [CodexReviewIdentity] + ) { + retain(identities, ownerToken: nil) + } + + package func waitForRestartWaiterCountForTesting( + tokenID: CodexReviewRestartToken.ID, + atLeast minimumCount: Int + ) async { + guard case .restarting(_, _, let completion) = statesByTokenID[tokenID] else { + preconditionFailure("A restart waiter can only be observed while restarting.") + } + await completion.waitForWaiterCount(atLeast: minimumCount) + } + + package func waitForInvalidationRequestForTesting( + tokenID: CodexReviewRestartToken.ID + ) async { + switch statesByTokenID[tokenID] { + case .preparing(_, let completion): + await completion.invalidation.waitUntilRequested() + case .restarting(_, _, let completion): + await completion.invalidation.waitUntilRequested() + case .invalidating, nil: + return + case .prepared: + preconditionFailure("An invalidation request cannot be observed before it starts.") + } + } + + private func finishPreparation( + tokenID: CodexReviewRestartToken.ID, + completion: PreparationCompletion, + outcome: PreparationExecutionOutcome, + callerWasCancelled: Bool + ) throws -> CodexReviewRestartToken { + guard case .preparing(var context, let currentCompletion) = statesByTokenID[tokenID], + currentCompletion.id == completion.id else { + if callerWasCancelled || completion.invalidation.isRequested { + throw CancellationError() + } + throw CodexAppServerError.reviewRestartUnavailable(tokenID) + } + retain(outcome.retainedIdentities, ownerToken: context.token) + if callerWasCancelled || completion.invalidation.isRequested { + statesByTokenID.removeValue(forKey: tokenID) + throw CancellationError() + } + switch outcome { + case .succeeded(let preparation, _): + context.rollbackThreadID = preparation.rollbackThreadID + context.rollbackModel = preparation.rollbackModel + statesByTokenID[tokenID] = .prepared(context) + return context.token + case .failed(let error, _): + statesByTokenID.removeValue(forKey: tokenID) + throw error + } + } + + private func executeRestart( + operationID: UUID, + context initialContext: Context, + operations: RestartOperations + ) async -> RestartExecutionOutcome { + var context = initialContext + let accumulator = ReviewRestartIdentityAccumulator() + + if context.rollbackCompleted == false { + let rollbackThread: CodexThread + do { + rollbackThread = try await operations.loadRollbackThread(context) + } catch { + return .failed( + error, + phase: .loadingRollbackThread, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + do { + try await operations.rollback(rollbackThread) + } catch { + return .failed( + error, + phase: .rollingBack, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + context.rollbackCompleted = true + guard commitRollback( + tokenID: context.token.id, + operationID: operationID, + context: context + ) else { + return .failed( + CancellationError(), + phase: .rollingBack, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + } + + do { + try Task.checkCancellation() + } catch { + return .failed( + error, + phase: .loadingSourceThread, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + + let sourceThread: CodexThread + do { + sourceThread = try await operations.loadSourceThread(context) + } catch { + return .failed( + error, + phase: .loadingSourceThread, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + + do { + try Task.checkCancellation() + } catch { + return .failed( + error, + phase: .startingReview, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + + do { + let review = try await operations.startReview(sourceThread, accumulator) + do { + try Task.checkCancellation() + } catch { + accumulator.record(review.identity) + await Self.cleanLateSession( + review, + using: operations.cleanupLateSession + ) + return .failed( + error, + phase: .startingReview, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + return .succeeded( + review, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } catch { + return .failed( + error, + phase: .startingReview, + context: context, + retainedIdentities: accumulator.snapshot() + ) + } + } + + private func commitRollback( + tokenID: CodexReviewRestartToken.ID, + operationID: UUID, + context: Context + ) -> Bool { + guard case .restarting(_, let signature, let completion) = statesByTokenID[tokenID], + completion.id == operationID, + completion.invalidation.isRequested == false else { + return false + } + precondition( + context.rollbackCompleted, + "A review restart must commit rollback before continuing." + ) + statesByTokenID[tokenID] = .restarting( + context, + signature: signature, + completion: completion + ) + return true + } + + private func finishRestart( + tokenID: CodexReviewRestartToken.ID, + completion: RestartCompletion, + outcome: RestartExecutionOutcome + ) async throws -> CodexReviewSession { + if let settlement = completion.settlement { + return try Self.value(from: settlement, tokenID: tokenID) + } + + guard case .restarting(_, _, let currentCompletion) = statesByTokenID[tokenID], + currentCompletion.id == completion.id else { + if let settlement = completion.settlement { + return try Self.value(from: settlement, tokenID: tokenID) + } + if completion.invalidation.isRequested { + throw CodexAppServerError.reviewRestartUnavailable(tokenID) + } + preconditionFailure("A shared review restart must settle exactly once.") + } + + if completion.invalidation.isRequested { + let state = statesByTokenID[tokenID]! + let invalidation = transitionToInvalidating( + tokenID: tokenID, + state: state + ) + _ = await invalidation.task.value + _ = await finishInvalidation( + tokenID: tokenID, + context: Self.context(from: state), + completion: invalidation + ) + return try Self.value( + from: completion.settlement ?? .unavailable, + tokenID: tokenID + ) + } + + retain( + outcome.retainedIdentities, + ownerToken: outcome.context.token + ) + switch outcome { + case .succeeded(let review, let context, _): + retain([review.identity], ownerToken: context.token) + statesByTokenID.removeValue(forKey: tokenID) + completion.setSettlement(.succeeded(review)) + case .failed(let error, let phase, let context, _): + let disposition = Self.failureDisposition(error: error, phase: phase) + if disposition == .retryPrepared, + context.restartAttemptsUsed < Self.maximumRestartAttempts { + statesByTokenID[tokenID] = .prepared(context) + completion.setSettlement(.failed(error)) + } else { + statesByTokenID.removeValue(forKey: tokenID) + if disposition == .retryPrepared { + completion.setSettlement(.unavailable) + } else { + completion.setSettlement(.failed(error)) + } + } + } + return try Self.value( + from: completion.settlement!, + tokenID: tokenID + ) + } + + private func transitionToInvalidating( + tokenID: CodexReviewRestartToken.ID, + state: State + ) -> InvalidationCompletion { + if case .invalidating(_, let completion) = state { + return completion + } + + let context = Self.context(from: state) + let operationID = UUID() + let task: Task<[CodexReviewIdentity], Never> + let restartCompletion: RestartCompletion? + switch state { + case .prepared: + task = Task { [] } + restartCompletion = nil + case .preparing(_, let preparation): + preparation.invalidation.request() + preparation.task.cancel() + task = Task { + let outcome = await preparation.task.value + return outcome.retainedIdentities + } + restartCompletion = nil + case .restarting(_, _, let restart): + restart.invalidation.request() + restart.task.cancel() + task = Task { + let outcome = await restart.task.value + var identities = outcome.retainedIdentities + if case .succeeded(let review, _, _) = outcome { + Self.merge([review.identity], into: &identities) + await Self.cleanLateSession( + review, + using: restart.cleanupLateSession + ) + } + return identities + } + restartCompletion = restart + case .invalidating: + preconditionFailure("An existing invalidation must be joined.") + } + let completion = InvalidationCompletion( + id: operationID, + task: task, + restartCompletion: restartCompletion + ) + statesByTokenID[tokenID] = .invalidating( + context, + completion: completion + ) + return completion + } + + private func finishInvalidation( + tokenID: CodexReviewRestartToken.ID, + context: Context, + completion: InvalidationCompletion + ) async -> [CodexReviewIdentity] { + if let result = completion.result { + return result + } + let lateIdentities = await completion.task.value + retain(lateIdentities, ownerToken: context.token) + + if case .invalidating(_, let currentCompletion) = statesByTokenID[tokenID], + currentCompletion.id == completion.id { + statesByTokenID.removeValue(forKey: tokenID) + } + completion.restartCompletion?.setSettlementIfUnset(.unavailable) + let identities = takeRetainedIdentities(ownerToken: context.token) + completion.setResultIfUnset(identities) + return completion.result ?? identities + } + + private func retain( + _ identities: [CodexReviewIdentity], + ownerToken: CodexReviewRestartToken? + ) { + for identity in identities { + let sourceThreadID = identity.sourceThreadID + let record = RetainedIdentityRecord( + identity: identity, + ownerToken: ownerToken + ) + if retainedIdentityRecordsBySourceThreadID[sourceThreadID, default: []] + .contains(where: { + $0.ownerToken == record.ownerToken + && sameReviewLifecycleIdentity($0.identity, record.identity) + }) == false { + retainedIdentityRecordsBySourceThreadID[sourceThreadID, default: []] + .append(record) + } + } + } + + private func takeRetainedIdentities( + sourceThreadID: CodexThreadID + ) -> [CodexReviewIdentity] { + let records = retainedIdentityRecordsBySourceThreadID.removeValue( + forKey: sourceThreadID + ) ?? [] + var identities: [CodexReviewIdentity] = [] + Self.merge(records.map(\.identity), into: &identities) + return identities + } + + private func takeRetainedIdentities( + ownerToken: CodexReviewRestartToken + ) -> [CodexReviewIdentity] { + let sourceThreadID = ownerToken.interruptedIdentity.sourceThreadID + guard var records = retainedIdentityRecordsBySourceThreadID[sourceThreadID] else { + return [] + } + let matching = records.filter { $0.ownerToken == ownerToken } + guard matching.isEmpty == false else { + return [] + } + records.removeAll { $0.ownerToken == ownerToken } + if records.isEmpty { + retainedIdentityRecordsBySourceThreadID.removeValue(forKey: sourceThreadID) + } else { + retainedIdentityRecordsBySourceThreadID[sourceThreadID] = records + } + var identities: [CodexReviewIdentity] = [] + Self.merge(matching.map(\.identity), into: &identities) + return identities + } + + private func requireMatchingToken( + _ token: CodexReviewRestartToken, + context: Context + ) throws { + guard context.token == token else { + throw CodexAppServerError.reviewRestartUnavailable(token.id) + } + } + + private nonisolated static func context(from state: State) -> Context { + switch state { + case .preparing(let context, _), .prepared(let context), + .restarting(let context, _, _), .invalidating(let context, _): + context + } + } + + private nonisolated static func cleanLateSession( + _ review: CodexReviewSession, + using cleanup: @Sendable (CodexReviewSession) async throws -> Void + ) async { + do { + try await cleanup(review) + } catch { + reviewRestartLogger.error( + "Failed to interrupt late review session \(review.turnID.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + } + + private nonisolated static func merge( + _ newIdentities: [CodexReviewIdentity], + into identities: inout [CodexReviewIdentity] + ) { + for identity in newIdentities { + if identities.contains(where: { + sameReviewLifecycleIdentity($0, identity) + }) == false { + identities.append(identity) + } + } + } + + private nonisolated static func failureDisposition( + error: any Error, + phase: RestartFailurePhase + ) -> RestartFailureDisposition { + if error is CancellationError { + return .invalidate + } + switch phase { + case .loadingSourceThread: + return .retryPrepared + case .loadingRollbackThread: + if case CodexAppServerError.connectionTerminated = error { + return .invalidate + } + return .retryPrepared + case .rollingBack, .startingReview: + guard let error = error as? CodexAppServerError else { + return .invalidate + } + switch error { + case .request(let failure): + switch failure.kind { + case .encode, .transport, .server, .overloadRetryExhausted: + return .retryPrepared + case .write, .invalidResponse, .deadlineExceeded: + return .invalidate + } + case .connectionTerminated: + return .invalidate + case .launch, .turnDeadlineExceeded, .malformedNotification, + .reviewRestartUnavailable, .loginAlreadyInProgress, + .invalidAPIKey, .authenticationOutcomeUnknown: + return .invalidate + } + } + } + + private nonisolated static func value( + from settlement: RestartSettlement, + tokenID: CodexReviewRestartToken.ID + ) throws -> CodexReviewSession { + switch settlement { + case .succeeded(let review): + return review + case .failed(let error): + throw error + case .unavailable: + throw CodexAppServerError.reviewRestartUnavailable(tokenID) + } + } +} + +private enum PreparationExecutionOutcome: Sendable { + case succeeded( + ReviewRestartCoordinator.Preparation, + retainedIdentities: [CodexReviewIdentity] + ) + case failed(any Error, retainedIdentities: [CodexReviewIdentity]) + + var retainedIdentities: [CodexReviewIdentity] { + switch self { + case .succeeded(_, let identities), .failed(_, let identities): + identities + } + } +} + +private enum RestartFailurePhase: Sendable { + case loadingRollbackThread + case rollingBack + case loadingSourceThread + case startingReview +} + +private enum RestartFailureDisposition: Equatable, Sendable { + case retryPrepared + case invalidate +} + +private enum RestartExecutionOutcome: Sendable { + case succeeded( + CodexReviewSession, + context: ReviewRestartCoordinator.Context, + retainedIdentities: [CodexReviewIdentity] + ) + case failed( + any Error, + phase: RestartFailurePhase, + context: ReviewRestartCoordinator.Context, + retainedIdentities: [CodexReviewIdentity] + ) + + var retainedIdentities: [CodexReviewIdentity] { + switch self { + case .succeeded(_, _, let identities), .failed(_, _, _, let identities): + identities + } + } + + var context: ReviewRestartCoordinator.Context { + switch self { + case .succeeded(_, let context, _), .failed(_, _, let context, _): + context + } + } +} + +private enum RestartSettlement: Sendable { + case succeeded(CodexReviewSession) + case failed(any Error) + case unavailable +} + +private struct RetainedIdentityRecord: Equatable, Sendable { + var identity: CodexReviewIdentity + var ownerToken: CodexReviewRestartToken? +} + +package final class PreparationCompletion: Sendable { + fileprivate let id: UUID + fileprivate let invalidation: InvalidationSignal + fileprivate let task: Task + + fileprivate init( + id: UUID, + invalidation: InvalidationSignal, + task: Task + ) { + self.id = id + self.invalidation = invalidation + self.task = task + } +} + +package final class RestartCompletion: Sendable { + fileprivate let id: UUID + fileprivate let invalidation: InvalidationSignal + fileprivate let task: Task + private let resultCell: SharedResultCell + fileprivate let cleanupLateSession: @Sendable (CodexReviewSession) async throws -> Void + private let storedSettlement = Mutex(nil) + + fileprivate init( + id: UUID, + invalidation: InvalidationSignal, + task: Task, + resultCell: SharedResultCell, + cleanupLateSession: @escaping @Sendable (CodexReviewSession) async throws -> Void + ) { + self.id = id + self.invalidation = invalidation + self.task = task + self.resultCell = resultCell + self.cleanupLateSession = cleanupLateSession + } + + fileprivate func wait() async throws -> RestartExecutionOutcome { + try await resultCell.wait() + } + + fileprivate func waitForWaiterCount(atLeast minimumCount: Int) async { + await resultCell.waitForWaiterCount(atLeast: minimumCount) + } + + fileprivate var settlement: RestartSettlement? { + storedSettlement.withLock { $0 } + } + + fileprivate func setSettlement(_ settlement: RestartSettlement) { + storedSettlement.withLock { stored in + precondition(stored == nil, "A shared restart completion must settle exactly once.") + stored = settlement + } + } + + fileprivate func setSettlementIfUnset(_ settlement: RestartSettlement) { + storedSettlement.withLock { stored in + if stored == nil { + stored = settlement + } + } + } +} + +package final class InvalidationCompletion: Sendable { + fileprivate let id: UUID + fileprivate let task: Task<[CodexReviewIdentity], Never> + fileprivate let restartCompletion: RestartCompletion? + private let storedResult = Mutex<[CodexReviewIdentity]?>(nil) + + fileprivate init( + id: UUID, + task: Task<[CodexReviewIdentity], Never>, + restartCompletion: RestartCompletion? + ) { + self.id = id + self.task = task + self.restartCompletion = restartCompletion + } + + fileprivate var result: [CodexReviewIdentity]? { + storedResult.withLock { $0 } + } + + fileprivate func setResultIfUnset(_ result: [CodexReviewIdentity]) { + storedResult.withLock { stored in + if stored == nil { + stored = result + } + } + } +} + +private final class InvalidationSignal: Sendable { + private struct State: Sendable { + var isRequested = false + var observers: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + + var isRequested: Bool { + state.withLock { $0.isRequested } + } + + func request() { + let observers = state.withLock { state in + state.isRequested = true + let observers = state.observers + state.observers.removeAll(keepingCapacity: false) + return observers + } + for observer in observers { + observer.resume() + } + } + + func waitUntilRequested() async { + let shouldWait = state.withLock { $0.isRequested == false } + guard shouldWait else { + return + } + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isRequested { + return true + } + state.observers.append(continuation) + return false + } + if shouldResume { + continuation.resume() + } + } + } +} + +private final class SharedResultCell: Sendable { + private struct WaiterCountObserver: Sendable { + var minimumCount: Int + var continuation: CheckedContinuation + } + + private struct State: Sendable { + var result: Value? + var waiters: [ + UUID: CheckedContinuation, Never> + ] = [:] + var waiterCountObservers: [WaiterCountObserver] = [] + } + + private let state = Mutex(State()) + + func wait() async throws -> Value { + try Task.checkCancellation() + let waiterID = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let registration = state.withLock { + state -> ( + Result?, + [CheckedContinuation] + ) in + if Task.isCancelled { + return (.failure(CancellationError()), []) + } + if let result = state.result { + return (.success(result), []) + } + state.waiters[waiterID] = continuation + let ready = state.waiterCountObservers.filter { + state.waiters.count >= $0.minimumCount + }.map(\.continuation) + state.waiterCountObservers.removeAll { + state.waiters.count >= $0.minimumCount + } + return (nil, ready) + } + for observer in registration.1 { + observer.resume() + } + if let immediate = registration.0 { + continuation.resume(returning: immediate) + } + } + } onCancel: { + let waiter = state.withLock { state in + state.waiters.removeValue(forKey: waiterID) + } + waiter?.resume(returning: .failure(CancellationError())) + } + return try result.get() + } + + func waitForWaiterCount(atLeast minimumCount: Int) async { + precondition(minimumCount > 0) + let shouldWait = state.withLock { state in + state.result == nil && state.waiters.count < minimumCount + } + guard shouldWait else { + return + } + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.result != nil || state.waiters.count >= minimumCount { + return true + } + state.waiterCountObservers.append(.init( + minimumCount: minimumCount, + continuation: continuation + )) + return false + } + if shouldResume { + continuation.resume() + } + } + } + + func resolve(_ result: Value) { + let waiters = state.withLock { state in + precondition(state.result == nil, "A shared result must resolve exactly once.") + state.result = result + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + let observers = state.waiterCountObservers.map(\.continuation) + state.waiterCountObservers.removeAll(keepingCapacity: false) + return (waiters, observers) + } + for waiter in waiters.0 { + waiter.resume(returning: .success(result)) + } + for observer in waiters.1 { + observer.resume() + } + } +} diff --git a/Sources/CodexAppServerKit/ServerRequestRegistry.swift b/Sources/CodexAppServerKit/ServerRequestRegistry.swift new file mode 100644 index 00000000..3dc0b41e --- /dev/null +++ b/Sources/CodexAppServerKit/ServerRequestRegistry.swift @@ -0,0 +1,346 @@ +import Foundation + +package enum ServerRequestTaskContext { + package struct Value: Equatable, Sendable { + package var requestID: CodexServerRequestID + package var token: UUID + } + + @TaskLocal package static var value: Value? +} + +package actor ServerRequestRegistry { + package enum Diagnostic: Equatable, Sendable { + case duplicateRequest(CodexServerRequestID) + case rejectedWhileClosing(CodexServerRequestID, method: String) + case decodeFailed(CodexServerRequestID, method: String, message: String) + case handlerFailed(CodexServerRequestID, method: String, message: String) + case responseFailed(CodexServerRequestID, method: String, message: String) + case ownedTaskRequestedClose(CodexServerRequestID) + } + + package typealias Responder = + @Sendable (CodexServerRequestID, CodexServerRequestResponse) async throws -> Void + package typealias DiagnosticHandler = @Sendable (Diagnostic) -> Void + + private enum Phase: Equatable { + case open + case closing + case closed + } + + private struct Entry { + var token: UUID + var task: Task + var suppressesResponse: Bool + var hasCommittedResponse: Bool + } + + private let codec: CodexAppServerRequestCodec + private let connectionEventHub: ConnectionEventHub + private let handler: CodexAppServerRequestHandler + private let responder: Responder + private let diagnosticHandler: DiagnosticHandler + private var phase: Phase = .open + private var entries: [CodexServerRequestID: Entry] = [:] + private var idleWaiters: [CheckedContinuation] = [] + private var receivedEventCount = 0 + private var receiveCountWaiters: [(Int, CheckedContinuation)] = [] + + package init( + codec: CodexAppServerRequestCodec = .init(), + connectionEventHub: ConnectionEventHub, + handler: @escaping CodexAppServerRequestHandler, + responder: @escaping Responder, + diagnosticHandler: @escaping DiagnosticHandler = { _ in } + ) { + self.codec = codec + self.connectionEventHub = connectionEventHub + self.handler = handler + self.responder = responder + self.diagnosticHandler = diagnosticHandler + } + + package func receive( + id: CodexServerRequestID, + method: String, + params: Data + ) async { + receivedEventCount += 1 + resumeReceiveCountWaiters() + guard phase == .open else { + emitDiagnostic(.rejectedWhileClosing(id, method: method)) + return + } + guard entries[id] == nil else { + emitDiagnostic(.duplicateRequest(id)) + return + } + + let token = UUID() + let context = ServerRequestTaskContext.Value(requestID: id, token: token) + let task = Task { [weak self, params] in + await ServerRequestTaskContext.$value.withValue(context) { + guard let self else { + return + } + await self.runRequest( + id: id, + token: token, + method: method, + params: params + ) + } + } + entries[id] = .init( + token: token, + task: task, + suppressesResponse: false, + hasCommittedResponse: false + ) + } + + package func resolve(_ id: CodexServerRequestID) async { + guard var entry = entries[id] else { + return + } + if entry.hasCommittedResponse == false { + entry.suppressesResponse = true + } + entries[id] = entry + entry.task.cancel() + await entry.task.value + removeEntryIfMatching(id: id, token: entry.token) + } + + package func cancelAllAndWait() async { + if phase == .open { + phase = .closing + } + guard phase != .closed else { + return + } + + let snapshot = entries + for (id, var entry) in snapshot { + if entry.hasCommittedResponse == false { + entry.suppressesResponse = true + } + entries[id] = entry + entry.task.cancel() + } + + for (id, entry) in snapshot { + await entry.task.value + removeEntryIfMatching(id: id, token: entry.token) + } + + entries.removeAll(keepingCapacity: false) + phase = .closed + resumeIdleWaitersIfNeeded() + } + + package func beginClosing() { + if phase == .open { + phase = .closing + } + } + + package func signalCloseIfOwned(by context: ServerRequestTaskContext.Value) -> Bool { + guard let ownedEntry = entries[context.requestID], + ownedEntry.token == context.token else { + return false + } + + if phase == .open { + phase = .closing + } + guard phase != .closed else { + return false + } + + for (id, var entry) in entries { + if entry.hasCommittedResponse == false { + entry.suppressesResponse = true + } + entries[id] = entry + entry.task.cancel() + } + emitDiagnostic(.ownedTaskRequestedClose(context.requestID)) + return true + } + + package func childCount() -> Int { + entries.count + } + + package func waitUntilIdle() async { + guard entries.isEmpty == false else { + return + } + await withCheckedContinuation { continuation in + idleWaiters.append(continuation) + } + } + + package func waitForReceivedEventCount(atLeast minimumCount: Int) async { + guard receivedEventCount < minimumCount else { + return + } + await withCheckedContinuation { continuation in + receiveCountWaiters.append((minimumCount, continuation)) + } + } + + private func runRequest( + id: CodexServerRequestID, + token: UUID, + method: String, + params: Data + ) async { + guard Task.isCancelled == false else { + finishSuppressed(id: id, token: token) + return + } + + let request: CodexAppServerRequest + do { + request = try codec.decode(method: method, params: params) + } catch { + guard Task.isCancelled == false else { + finishSuppressed(id: id, token: token) + return + } + emitDiagnostic(.decodeFailed( + id, + method: method, + message: error.localizedDescription + )) + await complete( + id: id, + token: token, + method: method, + response: CodexAppServerRequestCodec.internalError( + "Failed to decode \(method): \(error.localizedDescription)" + ) + ) + return + } + + let response: CodexServerRequestResponse + do { + let resolution = try await handler(request) + guard Task.isCancelled == false else { + finishSuppressed(id: id, token: token) + return + } + response = codec.response(to: request, resolution: resolution) + } catch { + guard Task.isCancelled == false else { + finishSuppressed(id: id, token: token) + return + } + emitDiagnostic(.handlerFailed( + id, + method: method, + message: error.localizedDescription + )) + response = CodexAppServerRequestCodec.internalError( + "Handler failed for \(method): \(error.localizedDescription)" + ) + } + + await complete(id: id, token: token, method: method, response: response) + } + + private func complete( + id: CodexServerRequestID, + token: UUID, + method: String, + response: CodexServerRequestResponse + ) async { + guard phase == .open, + var entry = entries[id], + entry.token == token, + entry.suppressesResponse == false else { + finishSuppressed(id: id, token: token) + return + } + entry.hasCommittedResponse = true + entries[id] = entry + await writeResponse(id: id, method: method, response: response) + removeEntryIfMatching(id: id, token: token) + resumeIdleWaitersIfNeeded() + } + + private func finishSuppressed(id: CodexServerRequestID, token: UUID) { + removeEntryIfMatching(id: id, token: token) + } + + private func removeEntryIfMatching(id: CodexServerRequestID, token: UUID) { + guard entries[id]?.token == token else { + return + } + entries.removeValue(forKey: id) + if phase == .closing, entries.isEmpty { + phase = .closed + } + resumeIdleWaitersIfNeeded() + } + + private func writeResponse( + id: CodexServerRequestID, + method: String, + response: CodexServerRequestResponse + ) async { + do { + try await responder(id, response) + } catch { + emitDiagnostic(.responseFailed( + id, + method: method, + message: error.localizedDescription + )) + } + } + + private func resumeIdleWaitersIfNeeded() { + guard entries.isEmpty else { + return + } + let waiters = idleWaiters + idleWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + private func emitDiagnostic(_ diagnostic: Diagnostic) { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.serverRequestRegistry(diagnostic) + )) + diagnosticHandler(diagnostic) + } + + private func resumeReceiveCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in receiveCountWaiters { + if receivedEventCount >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + receiveCountWaiters = remaining + } +} + +package struct CodexServerRequestResolvedNotification: Decodable, Sendable { + package var threadID: String + package var requestID: CodexServerRequestID + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case requestID = "requestId" + } +} diff --git a/Sources/CodexAppServerKit/ThreadEventHub.swift b/Sources/CodexAppServerKit/ThreadEventHub.swift new file mode 100644 index 00000000..691503d0 --- /dev/null +++ b/Sources/CodexAppServerKit/ThreadEventHub.swift @@ -0,0 +1,1704 @@ +import Foundation +import Synchronization + +package struct ThreadEventGenerationCheckpoint: Sendable { + fileprivate let identity: ThreadEventGenerationCheckpointIdentity +} + +package enum ThreadEventGenerationOperation: Equatable, Sendable { + case standard + case reviewStart(delivery: CodexReviewDelivery) +} + +package enum ThreadEventTurnStartDisposition: Equatable, Sendable { + case route + case suppress + case deferUntilOwned +} + +package struct CodexThreadEventSequence: AsyncSequence, Sendable { + package typealias Element = CodexThreadEvent + + private let channel: ThreadEventSubscriberChannel + private let cancellation: ThreadEventSubscriptionCancellation + + fileprivate init( + channel: ThreadEventSubscriberChannel, + cancellation: ThreadEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package func makeAsyncIterator() -> Iterator { + .init(channel: channel, cancellation: cancellation) + } + + package func cancel() { + cancellation.cancel() + } + + package struct Iterator: AsyncIteratorProtocol { + private let channel: ThreadEventSubscriberChannel + private let cancellation: ThreadEventSubscriptionCancellation + + fileprivate init( + channel: ThreadEventSubscriberChannel, + cancellation: ThreadEventSubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package mutating func next() async throws -> CodexThreadEvent? { + try await channel.next(cancellation: cancellation) + } + } +} + +package final class ThreadEventHub: Sendable { + package struct Snapshot: Equatable, Sendable { + package var subscriberCount: Int + package var pendingCheckpointCount: Int + package var hasActiveCheckpoint: Bool + package var hasCurrentGeneration: Bool + package var currentTurnID: CodexTurnID? + package var currentEventCount: Int + package var overflowCount: Int + package var isClosed: Bool + package var failure: CodexAppServerError? + package var threadStateCount: Int + } + + private enum Phase: Sendable { + case open + case failed(CodexAppServerError) + } + + private struct ThreadState: Sendable { + var current: ThreadEventGeneration? + var activeCheckpointID: UUID? + var subscribers: [UUID: ThreadEventSubscriberChannel] = [:] + var isClosed = false + var publicationRevision: UInt64 = 0 + } + + private struct CheckpointRecord: Sendable { + var identity: ThreadEventGenerationCheckpointIdentity + var threadID: CodexThreadID + var operation: ThreadEventGenerationOperation + var generation: ThreadEventGeneration? + } + + private struct State: Sendable { + var phase = Phase.open + var threads: [CodexThreadID: ThreadState] = [:] + var checkpoints: [UUID: CheckpointRecord] = [:] + } + + private let state = Mutex(State()) + // `removeSubscriber` and deinit cancellation must never acquire `delivery`: channel + // completion can wait on a task-status lock while its cancellation handler removes here. + private let delivery = Mutex(()) + + package init() {} + + deinit { + let channels = state.withLock { state in + let channels = state.threads.values.flatMap { $0.subscribers.values } + state.threads.removeAll(keepingCapacity: false) + state.checkpoints.removeAll(keepingCapacity: false) + return channels + } + for channel in channels { + channel.cancel() + } + } + + package func registerCheckpoint( + for threadID: CodexThreadID, + operation: ThreadEventGenerationOperation = .standard + ) throws -> ThreadEventGenerationCheckpoint { + let identity = ThreadEventGenerationCheckpointIdentity() + try state.withLock { state in + if case .failed(let error) = state.phase { + throw error + } + state.checkpoints[identity.id] = .init( + identity: identity, + threadID: threadID, + operation: operation, + generation: nil + ) + } + return .init(identity: identity) + } + + package func turnStartDisposition(for threadID: CodexThreadID) -> ThreadEventTurnStartDisposition { + state.withLock { state in + if let checkpointID = state.threads[threadID]?.activeCheckpointID, + let checkpoint = state.checkpoints[checkpointID] { + if case .reviewStart(let delivery) = checkpoint.operation { + return delivery == .inline ? .suppress : .route + } + return .route + } + return .deferUntilOwned + } + } + + package func activate(_ checkpoint: ThreadEventGenerationCheckpoint) { + state.withLock { state in + guard var record = state.checkpoints[checkpoint.identity.id] else { + switch checkpoint.identity.phase { + case .committed, .connectionTerminated: + return + case .inactive, .active, .discarded: + preconditionFailure("Only a registered generation checkpoint can be activated.") + } + } + precondition(record.identity === checkpoint.identity) + var thread = state.threads[record.threadID] ?? .init() + switch checkpoint.identity.phase { + case .inactive: + precondition( + thread.activeCheckpointID == nil, + "A thread can have at most one active generation checkpoint." + ) + checkpoint.identity.phase = .active + thread.activeCheckpointID = checkpoint.identity.id + record.generation = .init() + state.checkpoints[checkpoint.identity.id] = record + state.threads[record.threadID] = thread + case .active: + precondition( + thread.activeCheckpointID == checkpoint.identity.id, + "An active checkpoint must own the thread's active attempt." + ) + case .committed: + break + case .connectionTerminated: + break + case .discarded: + preconditionFailure("A discarded generation checkpoint cannot be activated.") + } + } + } + + package func seed( + _ snapshot: CodexTurnSnapshot, + at checkpoint: ThreadEventGenerationCheckpoint + ) throws { + try state.withLock { state in + guard var record = state.checkpoints[checkpoint.identity.id] else { + if checkpoint.identity.phase == .connectionTerminated { + return + } + preconditionFailure("Only a registered generation checkpoint can be seeded.") + } + precondition(record.identity === checkpoint.identity) + precondition( + checkpoint.identity.phase == .active, + "A generation checkpoint must be active when its response snapshot is seeded." + ) + var generation = record.generation ?? .init() + try generation.mergeResponseSnapshot(snapshot) + record.generation = generation + state.checkpoints[checkpoint.identity.id] = record + } + } + + package func seedProvisionalResumeSnapshot( + _ snapshot: CodexTurnSnapshot, + at checkpoint: ThreadEventGenerationCheckpoint + ) { + state.withLock { state in + guard var record = state.checkpoints[checkpoint.identity.id] else { + if checkpoint.identity.phase == .connectionTerminated { + return + } + preconditionFailure("Only a registered generation checkpoint can be seeded.") + } + precondition(record.identity === checkpoint.identity) + precondition( + checkpoint.identity.phase == .active, + "A generation checkpoint must be active when its response snapshot is seeded." + ) + var generation = record.generation ?? .init() + generation.mergeProvisionalResumeSnapshot(snapshot) + record.generation = generation + state.checkpoints[checkpoint.identity.id] = record + } + } + + package func resolveReviewStart( + _ checkpoint: ThreadEventGenerationCheckpoint, + eventThreadID: CodexThreadID, + responseSnapshot: CodexTurnSnapshot + ) throws { + var publication: ThreadEventPublication? + try state.withLock { state in + guard let record = state.checkpoints[checkpoint.identity.id] else { + preconditionFailure("Only an active review checkpoint can resolve a response.") + } + precondition(record.identity === checkpoint.identity) + precondition( + checkpoint.identity.phase == .active, + "A review checkpoint must remain active until its response identity resolves." + ) + guard case .reviewStart(let delivery) = record.operation else { + preconditionFailure("Only review/start may move its event generation.") + } + + switch delivery { + case .inline: + guard eventThreadID == record.threadID else { + throw CodexTransportFailure.contractViolation( + message: "An inline review cannot move to another event thread." + ) + } + case .detached: + guard eventThreadID != record.threadID else { + throw CodexTransportFailure.contractViolation( + message: "A detached review must use a different event thread." + ) + } + guard state.threads[eventThreadID] == nil else { + throw CodexTransportFailure.contractViolation( + message: "A detached review must use a previously unseen event thread." + ) + } + } + + var sourceThread = state.threads[record.threadID] ?? .init() + precondition(sourceThread.activeCheckpointID == checkpoint.identity.id) + sourceThread.activeCheckpointID = nil + + var generation = record.generation ?? .init() + try generation.mergeResponseSnapshot(responseSnapshot) + checkpoint.identity.phase = .committed + state.checkpoints.removeValue(forKey: checkpoint.identity.id) + + var eventThread: ThreadState + if eventThreadID == record.threadID { + eventThread = sourceThread + } else { + store(sourceThread, for: record.threadID, in: &state) + eventThread = state.threads[eventThreadID] ?? .init() + precondition( + eventThread.activeCheckpointID == nil, + "A detached review cannot replace an active event-thread request." + ) + } + eventThread.current = generation + eventThread.isClosed = generation.isClosed + let revision = nextPublicationRevision(for: &eventThread) + if generation.isClosed { + let channels = Array(eventThread.subscribers.values) + eventThread.subscribers.removeAll(keepingCapacity: false) + publication = .finish(channels, generation.replayEvents, revision) + } else { + publication = .supersede( + Array(eventThread.subscribers.values), + generation.replayEvents, + revision, + resetsGeneration: true + ) + } + state.threads[eventThreadID] = eventThread + } + _ = deliver(publication) + } + + package func reject(_ checkpoint: ThreadEventGenerationCheckpoint) { + state.withLock { state in + guard var record = state.checkpoints[checkpoint.identity.id] else { + switch checkpoint.identity.phase { + case .committed, .connectionTerminated: + return + case .inactive, .active, .discarded: + preconditionFailure("Only an active generation checkpoint can reject an attempt.") + } + } + precondition(record.identity === checkpoint.identity) + var thread = state.threads[record.threadID] ?? .init() + switch checkpoint.identity.phase { + case .active: + precondition(thread.activeCheckpointID == checkpoint.identity.id) + checkpoint.identity.phase = .inactive + thread.activeCheckpointID = nil + record.generation = nil + state.checkpoints[checkpoint.identity.id] = record + store(thread, for: record.threadID, in: &state) + case .inactive: + precondition(record.generation == nil) + case .committed: + break + case .connectionTerminated: + break + case .discarded: + preconditionFailure("A discarded generation checkpoint cannot reject an attempt.") + } + } + } + + package func commit(_ checkpoint: ThreadEventGenerationCheckpoint) { + commit(checkpoint, beforeDelivery: {}) + } + + package func commitForTesting( + _ checkpoint: ThreadEventGenerationCheckpoint, + beforeDelivery: @escaping @Sendable () -> Void + ) { + commit(checkpoint, beforeDelivery: beforeDelivery) + } + + private func commit( + _ checkpoint: ThreadEventGenerationCheckpoint, + beforeDelivery: @escaping @Sendable () -> Void + ) { + var publication: ThreadEventPublication? + state.withLock { state in + guard let record = state.checkpoints[checkpoint.identity.id] else { + switch checkpoint.identity.phase { + case .committed, .connectionTerminated: + return + case .inactive, .active, .discarded: + preconditionFailure("Only an active generation checkpoint can be committed.") + } + } + precondition(record.identity === checkpoint.identity) + precondition( + checkpoint.identity.phase == .active, + "A generation checkpoint must be active when its response commits." + ) + var thread = state.threads[record.threadID] ?? .init() + precondition(thread.activeCheckpointID == checkpoint.identity.id) + let generation = record.generation ?? .init() + checkpoint.identity.phase = .committed + thread.activeCheckpointID = nil + thread.current = generation + thread.isClosed = generation.isClosed + let revision = nextPublicationRevision(for: &thread) + state.checkpoints.removeValue(forKey: checkpoint.identity.id) + if generation.isClosed { + let channels = Array(thread.subscribers.values) + thread.subscribers.removeAll(keepingCapacity: false) + publication = .finish(channels, generation.replayEvents, revision) + } else { + publication = .supersede( + Array(thread.subscribers.values), + generation.replayEvents, + revision, + resetsGeneration: true + ) + } + state.threads[record.threadID] = thread + } + beforeDelivery() + _ = deliver(publication) + } + + package func discard(_ checkpoint: ThreadEventGenerationCheckpoint) { + state.withLock { state in + guard let record = state.checkpoints[checkpoint.identity.id] else { + precondition( + checkpoint.identity.phase == .committed + || checkpoint.identity.phase == .discarded + || checkpoint.identity.phase == .connectionTerminated, + "An unknown generation checkpoint cannot be discarded." + ) + return + } + precondition(record.identity === checkpoint.identity) + var thread = state.threads[record.threadID] ?? .init() + if thread.activeCheckpointID == checkpoint.identity.id { + thread.activeCheckpointID = nil + } + checkpoint.identity.phase = .discarded + state.checkpoints.removeValue(forKey: checkpoint.identity.id) + store(thread, for: record.threadID, in: &state) + } + } + + package func resetGeneration(for threadID: CodexThreadID) { + var publication: ThreadEventPublication? + state.withLock { state in + guard case .open = state.phase else { + return + } + var thread = state.threads[threadID] ?? .init() + precondition( + thread.activeCheckpointID == nil, + "A current generation cannot reset during an active request attempt." + ) + thread.current = .init() + thread.isClosed = false + let revision = nextPublicationRevision(for: &thread) + publication = .supersede( + Array(thread.subscribers.values), + [], + revision, + resetsGeneration: true + ) + state.threads[threadID] = thread + } + _ = deliver(publication) + } + + package func beginGeneration( + for threadID: CodexThreadID, + including turnID: CodexTurnID + ) { + var publication: ThreadEventPublication? + state.withLock { state in + guard case .open = state.phase else { + return + } + var thread = state.threads[threadID] ?? .init() + if thread.current?.turnID == turnID { + state.threads[threadID] = thread + return + } + + let matches = state.checkpoints.values.filter { + $0.threadID == threadID && $0.generation?.turnID == turnID + } + precondition( + matches.count <= 1, + "A detached turn can match at most one provisional thread generation." + ) + if let match = matches.first { + match.identity.phase = .committed + state.checkpoints.removeValue(forKey: match.identity.id) + if thread.activeCheckpointID == match.identity.id { + thread.activeCheckpointID = nil + } + thread.current = match.generation + } else if var current = thread.current, + current.hasProvisionalResumeSnapshot { + current.adoptProvisionalResumeIdentity(turnID) + thread.current = current + } else { + thread.current = .init(expectedTurnID: turnID) + } + thread.isClosed = false + let revision = nextPublicationRevision(for: &thread) + publication = .supersede( + Array(thread.subscribers.values), + thread.current?.replayEvents ?? [], + revision, + resetsGeneration: true + ) + state.threads[threadID] = thread + } + _ = deliver(publication) + } + + package func seedCurrentTurnSnapshot( + _ snapshot: CodexTurnSnapshot, + for threadID: CodexThreadID + ) { + var publication: ThreadEventPublication? + state.withLock { state in + guard case .open = state.phase else { + return + } + var thread = state.threads[threadID] ?? .init() + guard thread.activeCheckpointID == nil else { + return + } + let resetsGeneration: Bool + var generation: ThreadEventGeneration + if let current = thread.current { + if current.turnID == nil, current.hasProvisionalResumeSnapshot == false { + generation = current + resetsGeneration = true + } else if current.turnID == snapshot.id { + generation = current + resetsGeneration = false + } else { + guard current.isSupersededByHistoricalTurn(snapshot.id) else { + return + } + generation = .init() + resetsGeneration = true + } + } else { + generation = .init() + resetsGeneration = true + } + do { + try generation.mergeHistoricalSnapshot(snapshot) + } catch { + preconditionFailure( + "A matching current turn snapshot must merge into its generation: \(error)" + ) + } + thread.current = generation + thread.isClosed = generation.isClosed + let revision = nextPublicationRevision(for: &thread) + publication = .supersede( + Array(thread.subscribers.values), + generation.replayEvents, + revision, + resetsGeneration: resetsGeneration + ) + state.threads[threadID] = thread + } + _ = deliver(publication) + } + + package func events(for threadID: CodexThreadID) -> CodexThreadEventSequence { + events(for: threadID, beforePublication: {}) + } + + package func eventsForTesting( + for threadID: CodexThreadID, + beforePublication: @escaping @Sendable () -> Void + ) -> CodexThreadEventSequence { + events(for: threadID, beforePublication: beforePublication) + } + + private func events( + for threadID: CodexThreadID, + beforePublication: @escaping @Sendable () -> Void + ) -> CodexThreadEventSequence { + let subscriptionID = UUID() + let channel = ThreadEventSubscriberChannel() + let cancellation = ThreadEventSubscriptionCancellation( + id: subscriptionID, + threadID: threadID, + hub: self, + channel: channel + ) + beforePublication() + delivery.withLock { _ in + let publication = state.withLock { state -> ThreadEventInitialPublication in + switch state.phase { + case .failed(let error): + return .fail(error) + case .open: + var thread = state.threads[threadID] ?? .init() + if thread.isClosed { + return .finish( + thread.current?.replayEvents ?? [.closed], + thread.publicationRevision + ) + } else { + let publication = ThreadEventInitialPublication.supersede( + thread.current?.replayEvents ?? [], + thread.publicationRevision + ) + thread.subscribers[subscriptionID] = channel + state.threads[threadID] = thread + return publication + } + } + } + publication.deliver(to: channel) + } + return .init(channel: channel, cancellation: cancellation) + } + + @discardableResult + package func route( + _ event: CodexThreadEvent, + for threadID: CodexThreadID + ) throws -> Int { + let publication = try state.withLock { state -> ThreadEventPublication? in + if case .failed(let error) = state.phase { + throw error + } + var thread = state.threads[threadID] ?? .init() + if thread.isClosed { + if case .closed = event { + return nil + } + } + + if let activeID = thread.activeCheckpointID { + guard var record = state.checkpoints[activeID] else { + preconditionFailure("An active thread checkpoint lost its registration.") + } + // A detached review is required to run on a fresh response-identified thread. + // Explicit source-thread notifications therefore stay on the source generation; + // moving them with the request checkpoint would corrupt both thread histories. + if record.operation != .reviewStart(delivery: .detached) { + var generation = record.generation ?? .init() + _ = try generation.apply(event) + record.generation = generation + state.checkpoints[activeID] = record + state.threads[threadID] = thread + return nil + } + } + + let eventTurnID = Self.turnID(of: event) + let shouldRollGeneration = thread.isClosed + || thread.current?.isSupersededByObservedTurn(eventTurnID) == true + || ( + thread.current?.hasTerminal == true + && eventTurnID != nil + && eventTurnID != thread.current?.turnID + ) + var generation = shouldRollGeneration ? .init() : (thread.current ?? .init()) + let priorTurnID = generation.turnID + let disposition = try generation.apply(event) + guard disposition == .accepted else { + thread.current = generation + state.threads[threadID] = thread + return nil + } + thread.current = generation + thread.isClosed = generation.isClosed + let didEstablishTurn = priorTurnID == nil && generation.turnID != nil + let channels = Array(thread.subscribers.values) + let revision = nextPublicationRevision(for: &thread) + let publication: ThreadEventPublication + switch event { + case .terminal: + publication = .supersede( + channels, + generation.replayEvents, + revision, + resetsGeneration: shouldRollGeneration || didEstablishTurn + ) + case .closed: + thread.subscribers.removeAll(keepingCapacity: false) + publication = .finish(channels, generation.replayEvents, revision) + case .turnStarted, .snapshot, .itemStarted, .itemUpdated, .itemCompleted, + .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .statusChanged, .unknown: + if shouldRollGeneration || didEstablishTurn { + publication = .supersede( + channels, + generation.replayEvents, + revision, + resetsGeneration: true + ) + } else { + publication = .yield( + channels, + event, + generation.compactEvents, + revision + ) + } + } + state.threads[threadID] = thread + return publication + } + return deliver(publication) + } + + package func finish(throwing error: CodexAppServerError) { + let channels = state.withLock { state -> [ThreadEventSubscriberChannel] in + switch state.phase { + case .open: + state.phase = .failed(error) + case .failed(let existing): + precondition(existing == error, "A thread event hub failure cannot be replaced.") + return [] + } + for record in state.checkpoints.values { + record.identity.phase = .connectionTerminated + } + state.checkpoints.removeAll(keepingCapacity: false) + let channels = state.threads.values.flatMap { $0.subscribers.values } + state.threads.removeAll(keepingCapacity: false) + return channels + } + delivery.withLock { _ in + for channel in channels { + channel.fail(error) + } + } + } + + package func snapshotForTesting(threadID: CodexThreadID) -> Snapshot { + let captured = state.withLock { state -> ( + snapshot: Snapshot, + subscribers: [ThreadEventSubscriberChannel] + ) in + let thread = state.threads[threadID] + let failure: CodexAppServerError? + switch state.phase { + case .open: + failure = nil + case .failed(let error): + failure = error + } + let subscribers = thread.map { Array($0.subscribers.values) } ?? [] + return ( + snapshot: .init( + subscriberCount: subscribers.count, + pendingCheckpointCount: state.checkpoints.values.filter { + $0.threadID == threadID + }.count, + hasActiveCheckpoint: thread?.activeCheckpointID != nil, + hasCurrentGeneration: thread?.current != nil, + currentTurnID: thread?.current?.turnID, + currentEventCount: thread?.current?.replayEvents.count ?? 0, + overflowCount: 0, + isClosed: thread?.isClosed ?? false, + failure: failure, + threadStateCount: state.threads.count + ), + subscribers: subscribers + ) + } + var snapshot = captured.snapshot + snapshot.overflowCount = captured.subscribers.reduce(0) { + $0 + $1.overflowCountForTesting() + } + return snapshot + } + + fileprivate func removeSubscriber(_ id: UUID, threadID: CodexThreadID) { + state.withLock { state in + guard var thread = state.threads[threadID] else { + return + } + thread.subscribers.removeValue(forKey: id) + store(thread, for: threadID, in: &state) + } + } + + private func nextPublicationRevision(for thread: inout ThreadState) -> UInt64 { + thread.publicationRevision &+= 1 + return thread.publicationRevision + } + + private func deliver(_ publication: ThreadEventPublication?) -> Int { + guard let publication else { + return 0 + } + return delivery.withLock { _ in + publication.deliver() + } + } + + private func store( + _ thread: ThreadState, + for threadID: CodexThreadID, + in state: inout State + ) { + if thread.current == nil, + thread.activeCheckpointID == nil, + thread.subscribers.isEmpty, + thread.isClosed == false + { + state.threads.removeValue(forKey: threadID) + } else { + state.threads[threadID] = thread + } + } + + private static func turnID(of event: CodexThreadEvent) -> CodexTurnID? { + switch event { + case .turnStarted(let turnID): + turnID + case .snapshot(let snapshot): + snapshot.id + case .terminal(let outcome): + outcome.response.turnID + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .message(_, let turnID), + .messageDelta(_, let turnID), .reasoningSummaryPartAdded(_, let turnID), + .reasoningDelta(_, let turnID), .tokenUsageUpdated(_, let turnID): + turnID + case .diagnostic(_, let turnID): + turnID + case .unknown(let raw): + raw.turnID + case .statusChanged, .closed: + nil + } + } +} + +private final class ThreadEventGenerationCheckpointIdentity: Sendable { + enum Phase: Equatable, Sendable { + case inactive + case active + case committed + case connectionTerminated + case discarded + } + + let id = UUID() + private let state = Mutex(Phase.inactive) + + var phase: Phase { + get { state.withLock { $0 } } + set { state.withLock { $0 = newValue } } + } +} + +private struct ThreadEventGeneration: Equatable, Sendable { + enum ApplyDisposition: Equatable, Sendable { + case accepted + case duplicate + } + + private static let compactCapacity = 256 + private static let compactTailCapacity = compactCapacity - 3 + private static let postTerminalTailCapacity = compactCapacity - 1 + + private var snapshotReducer: CodexTurnSnapshotReducer? + private var latestUsage: CodexTokenUsage? + private var latestStatus: CodexThreadStatus? + private var compactTail: [CodexThreadEvent] = [] + private var postTerminalTail: [CodexThreadEvent] = [] + private var terminal: CodexTurnOutcome? + private var provisionalResumeSnapshot: CodexTurnSnapshot? + private var isHistoricalSnapshotOnly = false + private(set) var isClosed = false + + init(expectedTurnID: CodexTurnID? = nil) { + if let expectedTurnID { + snapshotReducer = .init(turnID: expectedTurnID) + } + } + + var turnID: CodexTurnID? { + snapshotReducer?.snapshot.id + } + + private var snapshot: CodexTurnSnapshot? { + snapshotReducer?.snapshot + } + + var hasTerminal: Bool { + if terminal != nil { + return true + } + switch snapshot?.state { + case .completed, .interrupted, .failed, .unknown: + return true + case .inProgress, nil: + return false + } + } + var hasProvisionalResumeSnapshot: Bool { + turnID == nil && provisionalResumeSnapshot != nil + } + + func isSupersededByObservedTurn(_ observedTurnID: CodexTurnID?) -> Bool { + isHistoricalSnapshotOnly + && observedTurnID != nil + && observedTurnID != turnID + } + + func isSupersededByHistoricalTurn(_ historicalTurnID: CodexTurnID) -> Bool { + turnID != historicalTurnID && (isHistoricalSnapshotOnly || hasTerminal) + } + + var compactEvents: [CodexThreadEvent] { + if let terminal { + return terminalReplayEvents(terminal) + } + var events: [CodexThreadEvent] = [] + if let snapshot { + events.append(.snapshot(snapshot)) + } + if let latestStatus { + events.append(.statusChanged(latestStatus)) + } + if let latestUsage { + events.append(.tokenUsageUpdated(latestUsage, turnID: turnID)) + } + events.append(contentsOf: compactTail) + precondition(events.count <= Self.compactCapacity) + return events + } + + var replayEvents: [CodexThreadEvent] { + var events = compactEvents + if isClosed { + events.append(.closed) + } + return events + } + + mutating func apply(_ event: CodexThreadEvent) throws -> ApplyDisposition { + if isClosed { + if case .closed = event { + return .duplicate + } + throw CodexTransportFailure.contractViolation( + message: "A compact thread generation received an event after thread/closed." + ) + } + + switch event { + case .turnStarted(let eventTurnID): + try establishTurn(eventTurnID) + try requireNonterminalTurnEvent("turn/started") + snapshotReducer?.markStarted() + + case .snapshot(let newSnapshot): + try establishTurn(newSnapshot.id) + try requireNonterminalTurnEvent("turn snapshot") + seed(newSnapshot) + compactTail.removeAll(keepingCapacity: true) + + case .terminal(let outcome): + let outcome = finalized(outcome) + try establishTurn(outcome.response.turnID) + guard var candidateReducer = snapshotReducer else { + preconditionFailure("A terminal turn requires a snapshot reducer.") + } + let compactSnapshot = candidateReducer.finish(outcome) + if let terminal { + guard terminal == compactSnapshot.outcome else { + throw CodexTransportFailure.contractViolation( + message: "Turn \(outcome.response.turnID.rawValue) reported conflicting terminal outcomes." + ) + } + return .duplicate + } + snapshotReducer = candidateReducer + terminal = compactSnapshot.outcome + + case .itemStarted(let item, let eventTurnID), + .itemUpdated(let item, let eventTurnID), + .itemCompleted(let item, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("item update") + guard turnID != nil else { + appendCompactTail(event) + return .accepted + } + upsert(item) + + case .message(let message, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("message") + guard turnID != nil else { + appendCompactTail(event) + return .accepted + } + upsert(.init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + )) + + case .messageDelta(let delta, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("message delta") + guard turnID != nil, let currentItem = delta.currentItem else { + appendCompactTail(event) + return .accepted + } + upsert(currentItem) + appendCompactTail(event) + + case .reasoningSummaryPartAdded(let part, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("reasoning part") + guard turnID != nil, let currentItem = part.currentItem else { + appendCompactTail(event) + return .accepted + } + upsert(currentItem) + appendCompactTail(event) + + case .reasoningDelta(let delta, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("reasoning delta") + guard turnID != nil, let currentItem = delta.currentItem else { + appendCompactTail(event) + return .accepted + } + upsert(currentItem) + appendCompactTail(event) + + case .tokenUsageUpdated(let usage, let eventTurnID): + try establishOptionalTurn(eventTurnID) + try requireNonterminalTurnEvent("token usage") + latestUsage = usage + + case .diagnostic(_, let eventTurnID): + try establishTurn(eventTurnID) + try requireNonterminalTurnEvent("turn diagnostic") + appendCompactTail(event) + + case .statusChanged(let status): + if terminal == nil { + latestStatus = status + } else { + appendPostTerminal(event) + } + + case .unknown: + if terminal == nil { + appendCompactTail(event) + } else { + appendPostTerminal(event) + } + + case .closed: + isClosed = true + } + return .accepted + } + + private mutating func establishOptionalTurn(_ eventTurnID: CodexTurnID?) throws { + if let eventTurnID { + try establishTurn(eventTurnID) + } + } + + private mutating func establishTurn(_ eventTurnID: CodexTurnID) throws { + if let turnID { + guard turnID == eventTurnID else { + throw CodexTransportFailure.contractViolation( + message: "One thread generation cannot contain turns \(turnID.rawValue) and \(eventTurnID.rawValue)." + ) + } + isHistoricalSnapshotOnly = false + return + } + if hasProvisionalResumeSnapshot { + adoptProvisionalResumeIdentity(eventTurnID) + } else { + snapshotReducer = .init(turnID: eventTurnID) + } + isHistoricalSnapshotOnly = false + } + + mutating func mergeResponseSnapshot(_ responseSnapshot: CodexTurnSnapshot) throws { + try establishTurn(responseSnapshot.id) + seed(responseSnapshot) + reconcileTerminalSnapshot() + } + + mutating func mergeHistoricalSnapshot(_ historicalSnapshot: CodexTurnSnapshot) throws { + if let provisionalResumeSnapshot { + guard provisionalResumeSnapshot.id == historicalSnapshot.id else { + throw CodexTransportFailure.contractViolation( + message: "A provisional resume snapshot cannot merge historical turn " + + "\(historicalSnapshot.id.rawValue) into " + + "\(provisionalResumeSnapshot.id.rawValue)." + ) + } + var reducer = CodexTurnSnapshotReducer(snapshot: provisionalResumeSnapshot) + reducer.merge(historicalSnapshot) + self.provisionalResumeSnapshot = reducer.snapshot + return + } + if let turnID { + guard turnID == historicalSnapshot.id else { + throw CodexTransportFailure.contractViolation( + message: "One thread generation cannot contain turns " + + "\(turnID.rawValue) and \(historicalSnapshot.id.rawValue)." + ) + } + seed(historicalSnapshot) + reconcileTerminalSnapshot() + return + } + snapshotReducer = .init(snapshot: historicalSnapshot) + isHistoricalSnapshotOnly = true + } + + mutating func mergeProvisionalResumeSnapshot(_ responseSnapshot: CodexTurnSnapshot) { + precondition( + responseSnapshot.state == .inProgress, + "Only an in-progress resume response can provisionally seed a live generation." + ) + guard let turnID else { + precondition( + provisionalResumeSnapshot == nil, + "A generation can receive one provisional response snapshot." + ) + // thread/resume reconstructs review turn IDs from rollout history. Keep that + // baseline private until a notification supplies the canonical live identity. + provisionalResumeSnapshot = responseSnapshot + return + } + var adoptedSnapshot = responseSnapshot + adoptedSnapshot.id = turnID + seed(adoptedSnapshot) + reconcileTerminalSnapshot() + } + + mutating func adoptProvisionalResumeIdentity(_ canonicalTurnID: CodexTurnID) { + precondition( + hasProvisionalResumeSnapshot, + "Only an identity-unbound resume snapshot can adopt a persisted live turn identity." + ) + provisionalResumeSnapshot?.id = canonicalTurnID + snapshotReducer = provisionalResumeSnapshot.map(CodexTurnSnapshotReducer.init(snapshot:)) + provisionalResumeSnapshot = nil + } + + private func requireNonterminalTurnEvent(_ name: StaticString) throws { + guard terminal == nil else { + throw CodexTransportFailure.contractViolation( + message: "A thread generation received \(name) after its terminal outcome." + ) + } + } + + private mutating func seed(_ newSnapshot: CodexTurnSnapshot) { + guard var snapshotReducer else { + self.snapshotReducer = .init(snapshot: newSnapshot) + return + } + snapshotReducer.merge(newSnapshot) + self.snapshotReducer = snapshotReducer + } + + private mutating func upsert(_ item: CodexThreadItem) { + guard var snapshotReducer else { + preconditionFailure("A compact item update requires a turn snapshot.") + } + snapshotReducer.observe(item) + self.snapshotReducer = snapshotReducer + } + + private mutating func reconcileTerminalSnapshot() { + guard let terminal else { + return + } + guard var snapshotReducer else { + preconditionFailure("A terminal turn requires a snapshot reducer.") + } + let compactSnapshot = snapshotReducer.finish(terminal) + self.snapshotReducer = snapshotReducer + self.terminal = compactSnapshot.outcome + } + + private func finalized(_ outcome: CodexTurnOutcome) -> CodexTurnOutcome { + guard outcome.response.usage == nil, let latestUsage else { + return outcome + } + var response = outcome.response + response.usage = latestUsage + switch outcome { + case .completed: + return .completed(response) + case .interrupted: + return .interrupted(response) + case .failed(let failed): + return .failed(.init(response: response, error: failed.error)) + case .invalidTerminalStatus(let rawStatus, let error, _): + return .invalidTerminalStatus( + rawStatus: rawStatus, + error: error, + response: response + ) + } + } + + private mutating func appendCompactTail(_ event: CodexThreadEvent) { + compactTail.append(event) + if compactTail.count > Self.compactTailCapacity { + compactTail.removeFirst(compactTail.count - Self.compactTailCapacity) + } + } + + private mutating func appendPostTerminal(_ event: CodexThreadEvent) { + postTerminalTail.append(event) + if postTerminalTail.count > Self.postTerminalTailCapacity { + postTerminalTail.removeFirst( + postTerminalTail.count - Self.postTerminalTailCapacity + ) + } + } + + private func terminalReplayEvents( + _ terminal: CodexTurnOutcome + ) -> [CodexThreadEvent] { + var events: [CodexThreadEvent] = [] + if let snapshot { + events.append(.snapshot(snapshot)) + } + events.append(.terminal(terminal)) + events.append(contentsOf: postTerminalTail) + precondition( + events.filter(\.isThreadBufferedIncrementalEvent).count <= Self.compactCapacity + ) + return events + } +} + +private enum ThreadEventPublication { + case yield( + [ThreadEventSubscriberChannel], + CodexThreadEvent, + [CodexThreadEvent], + UInt64 + ) + case supersede( + [ThreadEventSubscriberChannel], + [CodexThreadEvent], + UInt64, + resetsGeneration: Bool + ) + case finish([ThreadEventSubscriberChannel], [CodexThreadEvent], UInt64) + + func deliver() -> Int { + switch self { + case .yield(let channels, let event, let compactEvents, let revision): + return channels.reduce(into: 0) { overflowCount, channel in + overflowCount += channel.yield( + event, + compactEvents: compactEvents, + revision: revision + ) + } + case .supersede(let channels, let events, let revision, let resetsGeneration): + for channel in channels { + channel.supersede( + with: events, + revision: revision, + resetsGeneration: resetsGeneration + ) + } + return 0 + case .finish(let channels, let events, let revision): + for channel in channels { + channel.finish(with: events, revision: revision) + } + return 0 + } + } +} + +private enum ThreadEventInitialPublication { + case supersede([CodexThreadEvent], UInt64) + case finish([CodexThreadEvent], UInt64) + case fail(CodexAppServerError) + + func deliver(to channel: ThreadEventSubscriberChannel) { + switch self { + case .supersede(let events, let revision): + channel.supersede(with: events, revision: revision, resetsGeneration: true) + case .finish(let events, let revision): + channel.finish(with: events, revision: revision) + case .fail(let error): + channel.fail(error) + } + } +} + +private final class ThreadEventSubscriberChannel: Sendable { + private enum Phase: Sendable { + case open + case finishing + case failed(CodexAppServerError) + case finished + case cancelled + } + + private struct State: Sendable { + var pending: [CodexThreadEvent] = [] + var waiter: CheckedContinuation? + var phase = Phase.open + var nextIsActive = false + var overflowCount = 0 + var lastPublicationRevision: UInt64? + var currentTurnID: CodexTurnID? + var deliveredTerminal: CodexTurnOutcome? + } + + private static let incrementalCapacity = 256 + private let state = Mutex(State()) + + @discardableResult + func yield( + _ event: CodexThreadEvent, + compactEvents: [CodexThreadEvent], + revision: UInt64 + ) -> Int { + let result = state.withLock { state -> (ThreadEventWaiterDelivery?, Int) in + let hasRevisionGap = state.lastPublicationRevision.map { + revision > ($0 &+ 1) + } ?? false + guard case .open = state.phase, accept(revision, state: &state) else { + return (nil, 0) + } + if hasRevisionGap { + prepareGeneration( + for: compactEvents, + resetsGeneration: false, + state: &state + ) + state.pending = filtered(compactEvents, state: &state) + state.overflowCount += 1 + guard let waiter = state.waiter, state.pending.isEmpty == false else { + return (nil, 1) + } + state.waiter = nil + let next = state.pending.removeFirst() + recordTerminal(next, state: &state) + return (.value(waiter, next), 1) + } + prepareGeneration(for: [event], resetsGeneration: false, state: &state) + if let waiter = state.waiter { + state.waiter = nil + return (.value(waiter, event), 0) + } + if case .snapshot = event { + precondition( + compactEvents.filter(\.isThreadBufferedIncrementalEvent).count + <= Self.incrementalCapacity + ) + if state.pending.isEmpty { + state.pending.append(event) + } else { + let closed = state.pending.filter(\.isThreadClosedEvent) + state.pending = compactEvents + closed + } + return (nil, 0) + } + let pendingIncrementalCount = state.pending.reduce(into: 0) { count, pending in + if pending.isThreadBufferedIncrementalEvent { + count += 1 + } + } + if pendingIncrementalCount == Self.incrementalCapacity { + precondition( + compactEvents.filter(\.isThreadBufferedIncrementalEvent).count + <= Self.incrementalCapacity + ) + let closed = state.pending.filter(\.isThreadClosedEvent) + state.pending = compactEvents + closed + state.overflowCount += 1 + return (nil, 1) + } + if let closedIndex = state.pending.firstIndex(where: \.isThreadClosedEvent) { + state.pending.insert(event, at: closedIndex) + } else { + state.pending.append(event) + } + return (nil, 0) + } + result.0?.resume() + return result.1 + } + + func supersede( + with events: [CodexThreadEvent], + revision: UInt64, + resetsGeneration: Bool + ) { + let delivery = state.withLock { state -> ThreadEventWaiterDelivery? in + guard case .open = state.phase, accept(revision, state: &state) else { + return nil + } + prepareGeneration( + for: events, + resetsGeneration: resetsGeneration, + state: &state + ) + state.pending = filtered(events, state: &state) + guard let waiter = state.waiter, state.pending.isEmpty == false else { + return nil + } + state.waiter = nil + let event = state.pending.removeFirst() + recordTerminal(event, state: &state) + return .value(waiter, event) + } + delivery?.resume() + } + + func finish(with events: [CodexThreadEvent], revision: UInt64) { + let delivery = state.withLock { state -> ThreadEventWaiterDelivery? in + switch state.phase { + case .open: + let requiresReplay = state.lastPublicationRevision == nil + || state.lastPublicationRevision.map { + revision > ($0 &+ 1) + } == true + guard accept(revision, state: &state) else { + return nil + } + prepareGeneration(for: events, resetsGeneration: false, state: &state) + if requiresReplay { + state.pending = filtered(events, state: &state) + } else { + state.pending.append(contentsOf: events.filter(\.isThreadClosedEvent)) + } + state.phase = .finishing + guard let waiter = state.waiter else { + return nil + } + state.waiter = nil + if state.pending.isEmpty { + state.phase = .finished + return .finished(waiter) + } + let event = state.pending.removeFirst() + recordTerminal(event, state: &state) + return .value(waiter, event) + case .finishing, .finished, .cancelled: + return nil + case .failed(let existing): + preconditionFailure( + "A failed thread event subscriber cannot finish successfully: \(existing)." + ) + } + } + delivery?.resume() + } + + func fail(_ error: CodexAppServerError) { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.pending.removeAll(keepingCapacity: false) + state.phase = .failed(error) + let waiter = state.waiter + state.waiter = nil + return waiter + case .failed(let existing): + precondition(existing == error, "A thread subscriber failure cannot be replaced.") + return nil + case .finishing, .finished: + preconditionFailure("A normally finished thread subscriber cannot fail afterward.") + case .cancelled: + return nil + } + } + waiter?.resume(throwing: error) + } + + func next( + cancellation: ThreadEventSubscriptionCancellation + ) async throws -> CodexThreadEvent? { + precondition(tryBeginNext(), "CodexThreadEventSequence supports one in-flight next() call.") + defer { endNext() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let immediate = state.withLock { state -> Result? in + if state.pending.isEmpty == false { + let event = state.pending.removeFirst() + recordTerminal(event, state: &state) + if state.pending.isEmpty, case .finishing = state.phase { + state.phase = .finished + } + return .success(event) + } + switch state.phase { + case .open: + precondition(state.waiter == nil) + state.waiter = continuation + return nil + case .failed(let error): + state.phase = .finished + return .failure(error) + case .finishing, .finished: + state.phase = .finished + return .success(nil) + case .cancelled: + return .success(nil) + } + } + if let immediate { + continuation.resume(with: immediate) + } + } + } onCancel: { + cancellation.cancel() + } + } + + func cancel() { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open, .finishing, .failed: + state.phase = .cancelled + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + return waiter + case .finished, .cancelled: + return nil + } + } + waiter?.resume(returning: nil) + } + + func overflowCountForTesting() -> Int { + state.withLock { $0.overflowCount } + } + + private func filtered( + _ events: [CodexThreadEvent], + state: inout State + ) -> [CodexThreadEvent] { + events.filter { event in + guard case .terminal(let outcome) = event else { + return true + } + guard let delivered = state.deliveredTerminal else { + return true + } + return delivered.response.turnID != outcome.response.turnID + } + } + + private func recordTerminal(_ event: CodexThreadEvent, state: inout State) { + guard case .terminal(let outcome) = event else { + return + } + if let existing = state.deliveredTerminal, + existing.response.turnID == outcome.response.turnID + { + precondition(existing == outcome, "A subscriber cannot observe conflicting terminals.") + return + } + state.deliveredTerminal = outcome + } + + private func accept(_ revision: UInt64, state: inout State) -> Bool { + if let last = state.lastPublicationRevision, revision <= last { + return false + } + state.lastPublicationRevision = revision + return true + } + + private func prepareGeneration( + for events: [CodexThreadEvent], + resetsGeneration: Bool, + state: inout State + ) { + let eventTurnID = events.lazy.compactMap(\.threadTurnID).first + if resetsGeneration || (eventTurnID != nil && eventTurnID != state.currentTurnID) { + state.deliveredTerminal = nil + state.currentTurnID = eventTurnID + } else if state.currentTurnID == nil, let eventTurnID { + state.currentTurnID = eventTurnID + } + } + + private func tryBeginNext() -> Bool { + state.withLock { state in + guard state.nextIsActive == false else { + return false + } + state.nextIsActive = true + return true + } + } + + private func endNext() { + state.withLock { state in + precondition(state.nextIsActive) + state.nextIsActive = false + } + } +} + +private enum ThreadEventWaiterDelivery { + case value(CheckedContinuation, CodexThreadEvent) + case finished(CheckedContinuation) + + func resume() { + switch self { + case .value(let waiter, let event): + waiter.resume(returning: event) + case .finished(let waiter): + waiter.resume(returning: nil) + } + } +} + +private final class ThreadEventSubscriptionCancellation: Sendable { + private let state = Mutex(false) + private let id: UUID + private let threadID: CodexThreadID + private let hub: ThreadEventHub + private let channel: ThreadEventSubscriberChannel + + init( + id: UUID, + threadID: CodexThreadID, + hub: ThreadEventHub, + channel: ThreadEventSubscriberChannel + ) { + self.id = id + self.threadID = threadID + self.hub = hub + self.channel = channel + } + + func cancel() { + let shouldRemove = state.withLock { isCancelled in + guard isCancelled == false else { + return false + } + isCancelled = true + return true + } + if shouldRemove { + hub.removeSubscriber(id, threadID: threadID) + channel.cancel() + } + } + + deinit { + cancel() + } +} + +private extension CodexThreadEvent { + var threadTurnID: CodexTurnID? { + switch self { + case .turnStarted(let turnID): + turnID + case .snapshot(let snapshot): + snapshot.id + case .terminal(let outcome): + outcome.response.turnID + case .itemStarted(_, let turnID), .itemUpdated(_, let turnID), + .itemCompleted(_, let turnID), .message(_, let turnID), + .messageDelta(_, let turnID), .reasoningSummaryPartAdded(_, let turnID), + .reasoningDelta(_, let turnID), .tokenUsageUpdated(_, let turnID): + turnID + case .diagnostic(_, let turnID): + turnID + case .unknown(let raw): + raw.turnID + case .statusChanged, .closed: + nil + } + } + + var isThreadControlEvent: Bool { + switch self { + case .terminal, .closed: + true + case .turnStarted, .snapshot, .itemStarted, .itemUpdated, .itemCompleted, + .message, .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .statusChanged, .unknown: + false + } + } + + var isThreadBufferedIncrementalEvent: Bool { + switch self { + case .turnStarted, .itemStarted, .itemUpdated, .itemCompleted, .message, + .messageDelta, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .statusChanged, .unknown: + true + case .snapshot, .terminal, .closed: + false + } + } + + var isThreadClosedEvent: Bool { + if case .closed = self { + return true + } + return false + } +} diff --git a/Sources/CodexAppServerKit/TurnReplayCore.swift b/Sources/CodexAppServerKit/TurnReplayCore.swift new file mode 100644 index 00000000..323b43d7 --- /dev/null +++ b/Sources/CodexAppServerKit/TurnReplayCore.swift @@ -0,0 +1,891 @@ +import Foundation +import Synchronization + +package enum TurnReplayPendingOperationKind: Equatable, Sendable { + case turn(threadID: CodexThreadID) + case review(sourceThreadID: CodexThreadID, delivery: CodexReviewDelivery) +} + +package struct TurnReplayPendingToken: Hashable, Sendable { + fileprivate let rawValue: UUID + private let writePhase: TurnReplayPendingWritePhase + + package init() { + self.rawValue = UUID() + self.writePhase = TurnReplayPendingWritePhase() + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } + + package func acceptWrite() { + writePhase.acceptWrite() + } + + package func rejectAcceptedWrite() { + writePhase.rejectAcceptedWrite() + } + + package var isWriteAccepted: Bool { + writePhase.isWriteAccepted + } +} + +private final class TurnReplayPendingWritePhase: Sendable { + private let accepted = Mutex(false) + + var isWriteAccepted: Bool { + accepted.withLock { $0 } + } + + func acceptWrite() { + accepted.withLock { accepted in + precondition( + accepted == false, + "A turn operation attempt may accept its write exactly once." + ) + accepted = true + } + } + + func rejectAcceptedWrite() { + accepted.withLock { accepted in + precondition( + accepted, + "Only a write-accepted turn operation attempt can be rejected." + ) + accepted = false + } + } +} + +package struct CompactTurnSnapshot: Equatable, Sendable { + package let snapshot: CodexTurnSnapshot + package let outcome: CodexTurnOutcome + + package init(snapshot: CodexTurnSnapshot, outcome: CodexTurnOutcome) { + precondition( + snapshot.id == outcome.response.turnID, + "A compact turn snapshot and terminal outcome must identify the same turn." + ) + precondition( + snapshot.state.matches(outcome), + "A compact turn snapshot and outcome must describe the same terminal state." + ) + precondition( + snapshot.items == outcome.response.transcript.items, + "A compact turn snapshot and outcome must contain the same accumulated items." + ) + self.snapshot = snapshot + self.outcome = outcome + } + + package var replayEvents: [CodexTurnEvent] { + [.snapshot(snapshot), .terminal(outcome)] + } +} + +package struct TurnReplayEvents: AsyncSequence, Sendable { + package typealias Element = CodexTurnEvent + + private let channel: TurnReplayEventSubscriberChannel + private let cancellation: TurnReplaySubscriptionCancellation + + fileprivate init( + channel: TurnReplayEventSubscriberChannel, + cancellation: TurnReplaySubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package func makeAsyncIterator() -> Iterator { + .init(channel: channel, cancellation: cancellation) + } + + package func cancel() { + cancellation.cancel() + } + + package static func replaying(_ compactSnapshot: CompactTurnSnapshot) -> Self { + let registry = TurnReplaySubscriptionRegistry() + let subscription = registry.makeSubscription { + TurnReplayEventSubscriberChannel() + } + registry.finish { channel in + channel.finish(with: compactSnapshot) + } + return .init( + channel: subscription.channel, + cancellation: subscription.cancellation + ) + } + + package static func failing(_ error: CodexAppServerError) -> Self { + let registry = TurnReplaySubscriptionRegistry() + let subscription = registry.makeSubscription { + TurnReplayEventSubscriberChannel() + } + registry.finish { channel in + channel.finish(throwing: error) + } + return .init( + channel: subscription.channel, + cancellation: subscription.cancellation + ) + } + + package struct Iterator: AsyncIteratorProtocol { + private let channel: TurnReplayEventSubscriberChannel + private let cancellation: TurnReplaySubscriptionCancellation + + fileprivate init( + channel: TurnReplayEventSubscriberChannel, + cancellation: TurnReplaySubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package mutating func next() async throws -> CodexTurnEvent? { + try await channel.next(cancellation: cancellation) + } + } +} + +package struct TurnReplayProgressEvents: AsyncSequence, Sendable { + package typealias Element = CodexReviewProgress + + private let channel: TurnReplayProgressSubscriberChannel + private let cancellation: TurnReplaySubscriptionCancellation + + fileprivate init( + channel: TurnReplayProgressSubscriberChannel, + cancellation: TurnReplaySubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package func makeAsyncIterator() -> Iterator { + .init(channel: channel, cancellation: cancellation) + } + + package func cancel() { + cancellation.cancel() + } + + package static func replaying(_ compactSnapshot: CompactTurnSnapshot) -> Self { + let registry = TurnReplaySubscriptionRegistry() + let subscription = registry.makeSubscription { + TurnReplayProgressSubscriberChannel() + } + registry.finish { channel in + channel.finish(with: compactSnapshot.outcome) + } + return .init( + channel: subscription.channel, + cancellation: subscription.cancellation + ) + } + + package static func failing(_ error: CodexAppServerError) -> Self { + let registry = TurnReplaySubscriptionRegistry() + let subscription = registry.makeSubscription { + TurnReplayProgressSubscriberChannel() + } + registry.finish { channel in + channel.finish(throwing: error) + } + return .init( + channel: subscription.channel, + cancellation: subscription.cancellation + ) + } + + package struct Iterator: AsyncIteratorProtocol { + private let channel: TurnReplayProgressSubscriberChannel + private let cancellation: TurnReplaySubscriptionCancellation + + fileprivate init( + channel: TurnReplayProgressSubscriberChannel, + cancellation: TurnReplaySubscriptionCancellation + ) { + self.channel = channel + self.cancellation = cancellation + } + + package mutating func next() async throws -> CodexReviewProgress? { + try await channel.next(cancellation: cancellation) + } + } +} + +package final class TurnReplayRelay: Sendable { + package struct Snapshot: Equatable, Sendable { + package var subscriberCount: Int + package var overflowCount: Int + package var isFinished: Bool + } + + private let eventRegistry = TurnReplaySubscriptionRegistry() + private let progressRegistry = TurnReplaySubscriptionRegistry() + + package init() {} + + deinit { + eventRegistry.cancelAll() + progressRegistry.cancelAll() + } + + package func events(initialSnapshot: CodexTurnSnapshot? = nil) -> TurnReplayEvents { + events(initialSnapshot: initialSnapshot, beforePublication: {}) + } + + package func eventsForTesting( + initialSnapshot: CodexTurnSnapshot, + beforePublication: @escaping @Sendable () -> Void + ) -> TurnReplayEvents { + events( + initialSnapshot: initialSnapshot, + beforePublication: beforePublication + ) + } + + private func events( + initialSnapshot: CodexTurnSnapshot?, + beforePublication: @escaping @Sendable () -> Void + ) -> TurnReplayEvents { + let subscription = eventRegistry.makeSubscription( + makeChannel: { TurnReplayEventSubscriberChannel() }, + prepareForPublication: { channel in + beforePublication() + if let initialSnapshot { + channel.yield( + .snapshot(initialSnapshot), + accumulatedSnapshot: initialSnapshot + ) + } + } + ) + return .init(channel: subscription.channel, cancellation: subscription.cancellation) + } + + package func progressEvents( + initialProgress: CodexReviewProgress? = nil + ) -> TurnReplayProgressEvents { + progressEvents(initialProgress: initialProgress, beforePublication: {}) + } + + package func progressEventsForTesting( + initialProgress: CodexReviewProgress, + beforePublication: @escaping @Sendable () -> Void + ) -> TurnReplayProgressEvents { + progressEvents( + initialProgress: initialProgress, + beforePublication: beforePublication + ) + } + + private func progressEvents( + initialProgress: CodexReviewProgress?, + beforePublication: @escaping @Sendable () -> Void + ) -> TurnReplayProgressEvents { + let subscription = progressRegistry.makeSubscription( + makeChannel: { TurnReplayProgressSubscriberChannel() }, + prepareForPublication: { channel in + beforePublication() + if let initialProgress { + channel.yield(initialProgress) + } + } + ) + return .init(channel: subscription.channel, cancellation: subscription.cancellation) + } + + @discardableResult + package func yield( + _ event: CodexTurnEvent, + accumulatedSnapshot: CodexTurnSnapshot + ) -> Int { + precondition( + event.isTerminal == false, + "Turn replay terminal delivery must use finish(with:)." + ) + return eventRegistry.yield { (channel: TurnReplayEventSubscriberChannel) in + channel.yield(event, accumulatedSnapshot: accumulatedSnapshot) + } + } + + package func yieldProgress(_ progress: CodexReviewProgress) { + precondition( + progress.isTerminal == false, + "Turn replay terminal progress delivery must use finish(with:)." + ) + _ = progressRegistry.yield { (channel: TurnReplayProgressSubscriberChannel) in + channel.yield(progress) + } + } + + package func finish(with compactSnapshot: CompactTurnSnapshot) { + eventRegistry.finish { (channel: TurnReplayEventSubscriberChannel) in + channel.finish(with: compactSnapshot) + } + progressRegistry.finish { (channel: TurnReplayProgressSubscriberChannel) in + channel.finish(with: compactSnapshot.outcome) + } + } + + package func finish(throwing error: CodexAppServerError) { + eventRegistry.finish { (channel: TurnReplayEventSubscriberChannel) in + channel.finish(throwing: error) + } + progressRegistry.finish { (channel: TurnReplayProgressSubscriberChannel) in + channel.finish(throwing: error) + } + } + + package func snapshotForTesting() -> Snapshot { + let eventSnapshot = eventRegistry.snapshot() + return .init( + subscriberCount: eventSnapshot.subscriberCount, + overflowCount: eventSnapshot.overflowCount, + isFinished: eventSnapshot.isFinished + ) + } +} + +private protocol TurnReplaySubscriberChannel: AnyObject, Sendable { + associatedtype Element: Sendable + + func cancel() + func overflowCountForTesting() -> Int +} + +private final class TurnReplayEventSubscriberChannel: TurnReplaySubscriberChannel { + typealias Element = CodexTurnEvent + + private enum Phase { + case open + case finishing + case failed(CodexAppServerError) + case finished + case cancelled + } + + private struct State { + var pending: [CodexTurnEvent] = [] + var waiter: CheckedContinuation? + var phase = Phase.open + var nextIsActive = false + var overflowCount = 0 + } + + private static let incrementalCapacity = 256 + private let state = Mutex(State()) + + func yield(_ event: CodexTurnEvent, accumulatedSnapshot: CodexTurnSnapshot) { + let waiter = state.withLock { state -> CheckedContinuation? in + guard case .open = state.phase else { + return nil + } + if let waiter = state.waiter { + state.waiter = nil + return waiter + } + if case .snapshot = event { + state.pending = [event] + return nil + } + let snapshotPrefixCount = state.pending.first?.isSnapshot == true ? 1 : 0 + let pendingIncrementalCount = state.pending.count - snapshotPrefixCount + if pendingIncrementalCount == Self.incrementalCapacity { + state.pending = [.snapshot(accumulatedSnapshot)] + state.overflowCount += 1 + return nil + } + state.pending.append(event) + return nil + } + waiter?.resume(returning: event) + } + + func finish(with compactSnapshot: CompactTurnSnapshot) { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.pending = compactSnapshot.replayEvents + state.phase = .finishing + let waiter = state.waiter + state.waiter = nil + if waiter != nil { + state.pending.removeFirst() + } + return waiter + case .finishing, .finished: + return nil + case .failed(let existing): + preconditionFailure( + "A failed turn replay subscriber cannot finish successfully: \(existing)." + ) + case .cancelled: + return nil + } + } + waiter?.resume(returning: compactSnapshot.replayEvents[0]) + } + + func finish(throwing error: CodexAppServerError) { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.pending.removeAll(keepingCapacity: false) + state.phase = .failed(error) + let waiter = state.waiter + state.waiter = nil + return waiter + case .failed(let existing): + precondition(existing == error, "A turn replay failure cannot be replaced.") + return nil + case .finishing, .finished: + preconditionFailure("A terminal turn replay cannot fail afterward.") + case .cancelled: + return nil + } + } + waiter?.resume(throwing: error) + } + + func next( + cancellation: TurnReplaySubscriptionCancellation + ) async throws -> CodexTurnEvent? { + precondition(tryBeginNext(), "TurnReplayEvents supports one in-flight next() call.") + defer { endNext() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let immediate = state.withLock { state -> Result? in + if state.pending.isEmpty == false { + let event = state.pending.removeFirst() + if state.pending.isEmpty, case .finishing = state.phase { + state.phase = .finished + } + return .success(event) + } + switch state.phase { + case .open: + precondition(state.waiter == nil) + state.waiter = continuation + return nil + case .failed(let error): + state.phase = .finished + return .failure(error) + case .finishing, .finished: + state.phase = .finished + return .success(nil) + case .cancelled: + return .success(nil) + } + } + if let immediate { + continuation.resume(with: immediate) + } + } + } onCancel: { + cancellation.cancel() + } + } + + func cancel() { + let waiter = state.withLock { state -> CheckedContinuation? in + guard case .cancelled = state.phase else { + if case .finished = state.phase { + return nil + } + state.phase = .cancelled + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + return waiter + } + return nil + } + waiter?.resume(returning: nil) + } + + func overflowCountForTesting() -> Int { + state.withLock { $0.overflowCount } + } + + private func tryBeginNext() -> Bool { + state.withLock { state in + guard state.nextIsActive == false else { + return false + } + state.nextIsActive = true + return true + } + } + + private func endNext() { + state.withLock { state in + precondition(state.nextIsActive) + state.nextIsActive = false + } + } +} + +private final class TurnReplayProgressSubscriberChannel: TurnReplaySubscriberChannel { + typealias Element = CodexReviewProgress + + private enum Phase { + case open + case terminalPending(CodexTurnOutcome) + case failed(CodexAppServerError) + case finished + case cancelled + } + + private struct State { + var newestProgress: CodexReviewProgress? + var waiter: CheckedContinuation? + var phase = Phase.open + var nextIsActive = false + } + + private let state = Mutex(State()) + + func yield(_ progress: CodexReviewProgress) { + let waiter = state.withLock { state -> CheckedContinuation? in + guard case .open = state.phase else { + return nil + } + if let waiter = state.waiter { + state.waiter = nil + return waiter + } + state.newestProgress = progress + return nil + } + waiter?.resume(returning: progress) + } + + func finish(with outcome: CodexTurnOutcome) { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.phase = .terminalPending(outcome) + guard state.newestProgress == nil else { + return nil + } + let waiter = state.waiter + state.waiter = nil + if waiter != nil { + state.phase = .finished + } + return waiter + case .terminalPending(let existing): + precondition(existing == outcome, "Turn progress terminal cannot be replaced.") + return nil + case .failed(let existing): + preconditionFailure( + "A failed turn progress subscriber cannot finish successfully: \(existing)." + ) + case .finished, .cancelled: + return nil + } + } + waiter?.resume(returning: .terminal(outcome)) + } + + func finish(throwing error: CodexAppServerError) { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.newestProgress = nil + state.phase = .failed(error) + let waiter = state.waiter + state.waiter = nil + return waiter + case .failed(let existing): + precondition(existing == error, "A turn progress failure cannot be replaced.") + return nil + case .terminalPending, .finished: + preconditionFailure("Terminal turn progress cannot fail afterward.") + case .cancelled: + return nil + } + } + waiter?.resume(throwing: error) + } + + func next( + cancellation: TurnReplaySubscriptionCancellation + ) async throws -> CodexReviewProgress? { + precondition( + tryBeginNext(), + "TurnReplayProgressEvents supports one in-flight next() call." + ) + defer { endNext() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let immediate = state.withLock { state -> Result? in + if let progress = state.newestProgress { + state.newestProgress = nil + return .success(progress) + } + switch state.phase { + case .open: + precondition(state.waiter == nil) + state.waiter = continuation + return nil + case .terminalPending(let outcome): + state.phase = .finished + return .success(.terminal(outcome)) + case .failed(let error): + state.phase = .finished + return .failure(error) + case .finished, .cancelled: + return .success(nil) + } + } + if let immediate { + continuation.resume(with: immediate) + } + } + } onCancel: { + cancellation.cancel() + } + } + + func cancel() { + let waiter = state.withLock { state -> CheckedContinuation? in + guard case .cancelled = state.phase else { + if case .finished = state.phase { + return nil + } + state.phase = .cancelled + state.newestProgress = nil + let waiter = state.waiter + state.waiter = nil + return waiter + } + return nil + } + waiter?.resume(returning: nil) + } + + func overflowCountForTesting() -> Int { 0 } + + private func tryBeginNext() -> Bool { + state.withLock { state in + guard state.nextIsActive == false else { + return false + } + state.nextIsActive = true + return true + } + } + + private func endNext() { + state.withLock { state in + precondition(state.nextIsActive) + state.nextIsActive = false + } + } +} + +private final class TurnReplaySubscriptionCancellation: Sendable { + private struct State { + var isCancelled = false + } + + private let state = Mutex(State()) + private let id: UUID + private let registry: TurnReplaySubscriptionRegistry + + init(id: UUID, registry: TurnReplaySubscriptionRegistry) { + self.id = id + self.registry = registry + } + + func cancel() { + let shouldRemove = state.withLock { state in + guard state.isCancelled == false else { + return false + } + state.isCancelled = true + return true + } + if shouldRemove { + registry.remove(id) + } + } + + deinit { + cancel() + } +} + +private final class TurnReplaySubscriptionRegistry: Sendable { + private struct State { + var channels: [UUID: Channel] = [:] + var isFinished = false + } + + struct Subscription { + var channel: Channel + var cancellation: TurnReplaySubscriptionCancellation + } + + struct Snapshot { + var subscriberCount: Int + var overflowCount: Int + var isFinished: Bool + } + + private let state = Mutex(State()) + // `remove` and `cancelAll` must never acquire `delivery`: channel completion can wait on a + // subscriber task-status lock while that lock runs its cancellation handler back into remove. + private let delivery = Mutex(()) + + func makeSubscription( + makeChannel: () -> Channel, + prepareForPublication: (Channel) -> Void = { _ in } + ) -> Subscription { + let id = UUID() + let channel = makeChannel() + let cancellation = TurnReplaySubscriptionCancellation(id: id, registry: self) + delivery.withLock { _ in + precondition( + state.withLock { $0.isFinished == false }, + "Late replay must come from handle state." + ) + prepareForPublication(channel) + state.withLock { state in + precondition( + state.isFinished == false, + "A serialized replay publication cannot overtake finish." + ) + state.channels[id] = channel + } + } + return .init(channel: channel, cancellation: cancellation) + } + + @discardableResult + func yield( + _ body: (Channel) -> Void + ) -> Int { + delivery.withLock { _ in + let channels = state.withLock { state -> [Channel] in + guard state.isFinished == false else { + return [] + } + return Array(state.channels.values) + } + var overflows = 0 + for channel in channels { + let before = channel.overflowCountForTesting() + body(channel) + overflows += channel.overflowCountForTesting() - before + } + return overflows + } + } + + func remove(_ id: UUID) { + let channel = state.withLock { $0.channels.removeValue(forKey: id) } + channel?.cancel() + } + + func finish( + _ body: (Channel) -> Void + ) { + delivery.withLock { _ in + let channels = state.withLock { state -> [Channel] in + guard state.isFinished == false else { + return [] + } + state.isFinished = true + let channels = Array(state.channels.values) + state.channels.removeAll(keepingCapacity: false) + return channels + } + for channel in channels { + body(channel) + } + } + } + + func cancelAll() { + let channels = state.withLock { state -> [Channel] in + let channels = Array(state.channels.values) + state.channels.removeAll(keepingCapacity: false) + return channels + } + for channel in channels { + channel.cancel() + } + } + + func snapshot() -> Snapshot { + let captured = state.withLock { state in + (channels: Array(state.channels.values), isFinished: state.isFinished) + } + return .init( + subscriberCount: captured.channels.count, + overflowCount: captured.channels.reduce(0) { + $0 + $1.overflowCountForTesting() + }, + isFinished: captured.isFinished + ) + } +} + +private extension CodexTurnSnapshot.State { + func matches(_ outcome: CodexTurnOutcome) -> Bool { + switch (self, outcome) { + case (.completed, .completed), (.interrupted, .interrupted): + true + case (.failed(let snapshotError), .failed(let failedTurn)): + snapshotError == failedTurn.error + case ( + .unknown(let snapshotRawValue, let snapshotError), + .invalidTerminalStatus(let outcomeRawValue, let outcomeError, _) + ): + snapshotRawValue == outcomeRawValue && snapshotError == outcomeError + case (.inProgress, _), (.completed, _), (.interrupted, _), (.failed, _), (.unknown, _): + false + } + } +} + +private extension CodexTurnEvent { + var isTerminal: Bool { + if case .terminal = self { + return true + } + return false + } + + var isSnapshot: Bool { + if case .snapshot = self { + return true + } + return false + } +} + +private extension CodexReviewProgress { + var isTerminal: Bool { + if case .terminal = self { + return true + } + return false + } +} diff --git a/Sources/CodexAppServerKit/TurnReplayStore.swift b/Sources/CodexAppServerKit/TurnReplayStore.swift new file mode 100644 index 00000000..5327952a --- /dev/null +++ b/Sources/CodexAppServerKit/TurnReplayStore.swift @@ -0,0 +1,986 @@ +import Foundation + +package actor TurnGenerationHandleState { + package enum Snapshot: Equatable, Sendable { + case live + case terminal(CompactTurnSnapshot) + case terminated(CodexConnectionTermination) + } + + package enum TransitionDisposition: Equatable, Sendable { + case transitioned + case duplicate + case late + } + + private enum Phase { + case live(AppServerConnectionLease) + case terminal(CompactTurnSnapshot) + case terminated(CodexConnectionTermination) + } + + private var phase: Phase + + package init(connectionLease: AppServerConnectionLease) { + self.phase = .live(connectionLease) + } + + package func snapshot() -> Snapshot { + switch phase { + case .live: + .live + case .terminal(let compactSnapshot): + .terminal(compactSnapshot) + case .terminated(let termination): + .terminated(termination) + } + } + + @discardableResult + package func transitionToTerminal( + _ compactSnapshot: CompactTurnSnapshot + ) -> TransitionDisposition { + switch phase { + case .live: + phase = .terminal(compactSnapshot) + return .transitioned + case .terminal(let existing): + precondition( + existing == compactSnapshot, + "A turn generation cannot replace its terminal snapshot." + ) + return .duplicate + case .terminated: + return .late + } + } + + @discardableResult + package func transitionToTerminated( + _ termination: CodexConnectionTermination + ) -> TransitionDisposition { + switch phase { + case .live: + phase = .terminated(termination) + return .transitioned + case .terminal: + return .late + case .terminated(let existing): + precondition( + existing == termination, + "A turn generation cannot replace its connection termination." + ) + return .duplicate + } + } + + package func cachedOutcome() throws -> CodexTurnOutcome? { + switch phase { + case .live: + nil + case .terminal(let compactSnapshot): + compactSnapshot.outcome + case .terminated(let termination): + throw CodexAppServerError.connectionTerminated(termination) + } + } + + package func terminalEvents() throws -> TurnReplayEvents? { + switch phase { + case .live: + nil + case .terminal(let compactSnapshot): + .replaying(compactSnapshot) + case .terminated(let termination): + .failing(.connectionTerminated(termination)) + } + } + + package func terminalProgressEvents() throws -> TurnReplayProgressEvents? { + switch phase { + case .live: + nil + case .terminal(let compactSnapshot): + .replaying(compactSnapshot) + case .terminated(let termination): + .failing(.connectionTerminated(termination)) + } + } + + package func closeConnection() async { + guard case .live(let connectionLease) = phase else { + return + } + await connectionLease.closeConnection() + } + + package func connectionLeaseForSiblingGeneration() throws -> AppServerConnectionLease { + switch phase { + case .live(let connectionLease): + return connectionLease + case .terminal: + throw CodexTransportFailure.contractViolation( + message: "A terminal turn handle no longer retains the connection lease needed to observe a different generation." + ) + case .terminated(let termination): + throw CodexAppServerError.connectionTerminated(termination) + } + } +} + +package actor TurnReplayStore { + package enum RoutingDisposition: Equatable, Sendable { + case routed(overflowCount: Int) + case untracked + } + + package enum TerminalRoutingDisposition: Equatable, Sendable { + case routed + case duplicate + case untracked + } + + package enum PendingCancellationDisposition: Equatable, Sendable { + case removedBeforeWrite + case retainedAfterWrite + case notRegistered + } + + package struct RestoredGenerationReservation: Sendable { + fileprivate let turnID: CodexTurnID + package let state: TurnGenerationHandleState + fileprivate let token: UUID + } + + package struct Snapshot: Equatable, Sendable { + package var pendingOperationCount: Int + package var postWritePendingOperationCount: Int + package var activeGenerationCount: Int + package var orphanGenerationCount: Int + package var terminalOrphanCount: Int + package var weakStateRegistrationCount: Int + package var termination: CodexConnectionTermination? + } + + private enum StorePhase { + case open + case terminating(CodexConnectionTermination) + case terminated(CodexConnectionTermination) + } + + private struct PendingOperation { + var kind: TurnReplayPendingOperationKind + var token: TurnReplayPendingToken + var state: WeakTurnGenerationHandleState + } + + private final class Generation { + enum Phase { + case active + case terminalPendingBind(CompactTurnSnapshot) + case finalizing(CompactTurnSnapshot) + case terminating(CodexConnectionTermination) + } + + let turnID: CodexTurnID + var operationKind: TurnReplayPendingOperationKind? + var phase = Phase.active + var accumulator: TurnReplayAccumulator + let relay = TurnReplayRelay() + var state: WeakTurnGenerationHandleState? + var provisionalRestorationTokens: Set = [] + var isPublished = true + + init(turnID: CodexTurnID) { + self.turnID = turnID + self.accumulator = TurnReplayAccumulator(turnID: turnID) + } + + func register(_ state: TurnGenerationHandleState) { + if let existing = self.state?.value { + precondition( + existing === state, + "Every handle copy for a generation must share one state identity." + ) + return + } + self.state = .init(state) + } + + func liveState() -> TurnGenerationHandleState? { + state?.value + } + } + + private var phase = StorePhase.open + private var pendingOperations: [TurnReplayPendingToken: PendingOperation] = [:] + private var generations: [CodexTurnID: Generation] = [:] + private var orphanGenerations: [CodexTurnID: Generation] = [:] + private var terminationWaiters: [CheckedContinuation] = [] + + package init() {} + + package func registerPendingOperation( + kind: TurnReplayPendingOperationKind, + state: TurnGenerationHandleState + ) -> TurnReplayPendingToken { + requireOpen() + let token = TurnReplayPendingToken() + pendingOperations[token] = .init( + kind: kind, + token: token, + state: .init(state) + ) + return token + } + + @discardableResult + package func cancelPendingOperation( + _ token: TurnReplayPendingToken + ) -> PendingCancellationDisposition { + guard let operation = pendingOperations[token] else { + return .notRegistered + } + if operation.token.isWriteAccepted == false { + pendingOperations.removeValue(forKey: token) + return .removedBeforeWrite + } + precondition( + operation.state.value != nil, + "Post-write generation state must remain alive until binding or termination." + ) + return .retainedAfterWrite + } + + package func waitForTermination( + retaining state: TurnGenerationHandleState + ) async { + switch phase { + case .terminated: + return + case .open, .terminating: + await withCheckedContinuation { continuation in + terminationWaiters.append(continuation) + } + withExtendedLifetime(state) {} + } + } + + package func bind( + _ token: TurnReplayPendingToken, + to turnID: CodexTurnID, + initialSnapshot: CodexTurnSnapshot + ) async { + requireOpen() + precondition( + initialSnapshot.id == turnID, + "A generation binding snapshot must identify its bound turn." + ) + guard let operation = pendingOperations[token] else { + preconditionFailure("A turn generation may bind only from a pending operation.") + } + guard operation.token.isWriteAccepted else { + preconditionFailure("A turn generation cannot bind before its request write.") + } + guard let state = operation.state.value else { + preconditionFailure( + "The structured request scope released generation state before binding." + ) + } + precondition( + generations[turnID] == nil, + "A turn generation identity may be bound exactly once." + ) + + pendingOperations.removeValue(forKey: token) + if let orphan = orphanGenerations[turnID] { + orphan.operationKind = operation.kind + orphan.register(state) + switch orphan.phase { + case .active: + orphan.accumulator.seed(initialSnapshot) + orphanGenerations.removeValue(forKey: turnID) + generations[turnID] = orphan + case .terminalPendingBind(let compactSnapshot): + _ = await state.transitionToTerminal(compactSnapshot) + if orphanGenerations[turnID] === orphan { + orphanGenerations.removeValue(forKey: turnID) + } else if case .open = phase { + preconditionFailure("A terminal orphan changed identity during handoff.") + } + case .finalizing: + preconditionFailure("An unbound generation cannot already be finalizing.") + case .terminating: + preconditionFailure("A terminating store cannot bind an orphan generation.") + } + return + } + + let remainingPostWriteCount = pendingOperations.values.reduce(into: 0) { + count, pending in + if pending.token.isWriteAccepted { + count += 1 + } + } + precondition( + orphanGenerations.count <= remainingPostWriteCount, + "A bound response identity cannot strand an early orphan generation." + ) + + let generation = Generation(turnID: turnID) + generation.operationKind = operation.kind + generation.accumulator.seed(initialSnapshot) + generation.register(state) + generations[turnID] = generation + } + + package func register( + _ state: TurnGenerationHandleState, + for turnID: CodexTurnID + ) { + requireOpen() + guard let generation = generations[turnID] else { + preconditionFailure("A live handle may register only to a bound generation.") + } + guard case .active = generation.phase else { + preconditionFailure("A finalizing generation cannot accept a live handle.") + } + generation.register(state) + } + + package func restoreGeneration( + turnID: CodexTurnID, + initialSnapshot: CodexTurnSnapshot, + connectionLease: AppServerConnectionLease + ) -> TurnGenerationHandleState { + requireOpen() + precondition( + initialSnapshot.id == turnID, + "A restored generation snapshot must identify its turn." + ) + precondition( + orphanGenerations[turnID] == nil, + "A restored generation cannot replace an unbound request generation." + ) + if let generation = generations[turnID] { + guard case .active = generation.phase else { + preconditionFailure("A finalized generation must be restored from its handle state.") + } + generation.accumulator.seed(initialSnapshot) + generation.isPublished = true + if let existing = generation.liveState() { + return existing + } + let state = TurnGenerationHandleState(connectionLease: connectionLease) + generation.register(state) + return state + } + + let state = TurnGenerationHandleState(connectionLease: connectionLease) + let generation = Generation(turnID: turnID) + generation.accumulator.seed(initialSnapshot) + generation.register(state) + generations[turnID] = generation + return state + } + + package func reserveRestoredGeneration( + turnID: CodexTurnID, + initialSnapshot: CodexTurnSnapshot, + connectionLease: AppServerConnectionLease + ) -> RestoredGenerationReservation { + requireOpen() + precondition( + initialSnapshot.id == turnID, + "A reserved restored-generation snapshot must identify its turn." + ) + precondition( + orphanGenerations[turnID] == nil, + "A restored generation reservation cannot replace an unbound request generation." + ) + let token = UUID() + if let generation = generations[turnID] { + guard case .active = generation.phase else { + preconditionFailure("A finalized generation cannot accept a restore reservation.") + } + generation.accumulator.seed(initialSnapshot) + let state: TurnGenerationHandleState + if let existing = generation.liveState() { + state = existing + } else { + state = TurnGenerationHandleState(connectionLease: connectionLease) + generation.register(state) + } + precondition(generation.provisionalRestorationTokens.insert(token).inserted) + return .init(turnID: turnID, state: state, token: token) + } + + let state = TurnGenerationHandleState(connectionLease: connectionLease) + let generation = Generation(turnID: turnID) + generation.isPublished = false + generation.accumulator.seed(initialSnapshot) + generation.register(state) + precondition(generation.provisionalRestorationTokens.insert(token).inserted) + generations[turnID] = generation + return .init(turnID: turnID, state: state, token: token) + } + + package func commitRestoredGeneration( + _ reservation: RestoredGenerationReservation + ) async { + guard let generation = generations[reservation.turnID] else { + await requireFinalizedReservationState(reservation.state) + return + } + requireReservation(reservation, in: generation) + precondition( + generation.provisionalRestorationTokens.remove(reservation.token) != nil, + "A restored generation reservation may be committed or discarded exactly once." + ) + generation.isPublished = true + switch generation.phase { + case .active: + return + case .finalizing(let compactSnapshot): + _ = await reservation.state.transitionToTerminal(compactSnapshot) + case .terminating(let termination): + _ = await reservation.state.transitionToTerminated(termination) + case .terminalPendingBind: + preconditionFailure("A restored generation cannot be pending its initial bind.") + } + } + + @discardableResult + package func discardRestoredGeneration( + _ reservation: RestoredGenerationReservation + ) async -> Bool { + guard let generation = generations[reservation.turnID] else { + await requireFinalizedReservationState(reservation.state) + return false + } + requireReservation(reservation, in: generation) + precondition( + generation.provisionalRestorationTokens.remove(reservation.token) != nil, + "A restored generation reservation may be committed or discarded exactly once." + ) + guard case .active = generation.phase, + generation.isPublished == false, + generation.provisionalRestorationTokens.isEmpty else { + return false + } + precondition( + generations.removeValue(forKey: reservation.turnID) === generation, + "A discarded provisional generation changed identity." + ) + return true + } + + package func events( + for turnID: CodexTurnID, + state: TurnGenerationHandleState + ) async throws -> TurnReplayEvents { + if let generation = generations[turnID] { + switch generation.phase { + case .active: + generation.register(state) + return generation.relay.events( + initialSnapshot: generation.accumulator.snapshot + ) + case .finalizing(let compactSnapshot): + return .replaying(compactSnapshot) + case .terminating(let termination): + return .failing(.connectionTerminated(termination)) + case .terminalPendingBind: + preconditionFailure("A bound generation cannot be pending its initial bind.") + } + } + return try await terminalEventsAfterFinalization(for: turnID, state: state) + } + + package func progressEvents( + for turnID: CodexTurnID, + state: TurnGenerationHandleState + ) async throws -> TurnReplayProgressEvents { + if let generation = generations[turnID] { + switch generation.phase { + case .active: + generation.register(state) + return generation.relay.progressEvents( + initialProgress: generation.accumulator.progress + ) + case .finalizing(let compactSnapshot): + return .replaying(compactSnapshot) + case .terminating(let termination): + return .failing(.connectionTerminated(termination)) + case .terminalPendingBind: + preconditionFailure("A bound generation cannot be pending its initial bind.") + } + } + return try await terminalProgressEventsAfterFinalization( + for: turnID, + state: state + ) + } + + @discardableResult + package func yield( + _ event: CodexTurnEvent, + for turnID: CodexTurnID + ) -> Int { + switch routeIfTracked(event, for: turnID) { + case .routed(let overflowCount): + return overflowCount + case .untracked: + preconditionFailure("A strict turn replay yield requires a tracked generation.") + } + } + + package func routeIfTracked( + _ event: CodexTurnEvent, + for turnID: CodexTurnID, + allowsOrphanGeneration: Bool = true + ) -> RoutingDisposition { + guard case .open = phase else { + return .untracked + } + if case .terminal = event { + preconditionFailure("Turn replay terminal delivery must use finish(_:).") + } + let existingGeneration = generations[turnID] ?? orphanGenerations[turnID] + // Do not use turn/started as an unbound response identity. Reviews can expose an + // internal reviewer turn here while their response and substantive events use the + // canonical outer turn; the response snapshot or a substantive event owns binding. + if existingGeneration == nil, case .started = event { + return .untracked + } + guard let generation = existingGeneration ?? generationForRoutingIfTracked( + turnID, + allowsOrphanGeneration: allowsOrphanGeneration + ) else { + return .untracked + } + guard case .active = generation.phase else { + preconditionFailure("A terminal generation cannot accept another event.") + } + generation.accumulator.apply(event) + let overflowCount = generation.relay.yield( + event, + accumulatedSnapshot: generation.accumulator.snapshot + ) + generation.relay.yieldProgress(generation.accumulator.progress) + return .routed(overflowCount: overflowCount) + } + + package func isActiveReviewGeneration(_ turnID: CodexTurnID) -> Bool { + guard let generation = generations[turnID], + case .active = generation.phase, + case .review = generation.operationKind else { + return false + } + return true + } + + package func hasWriteAcceptedNonDetachedOperation(for threadID: CodexThreadID) -> Bool { + pendingOperations.values.contains { operation in + guard operation.token.isWriteAccepted else { + return false + } + switch operation.kind { + case .turn(let operationThreadID): + return operationThreadID == threadID + case .review(let sourceThreadID, delivery: .inline): + return sourceThreadID == threadID + case .review(_, delivery: .detached): + return false + } + } + } + + package func finish(_ outcome: CodexTurnOutcome) async { + guard await finishIfTracked(outcome) != .untracked else { + preconditionFailure("A strict turn replay finish requires a tracked generation.") + } + } + + package func finishIfTracked( + _ outcome: CodexTurnOutcome, + allowsOrphanGeneration: Bool = true + ) async -> TerminalRoutingDisposition { + guard case .open = phase else { + return .untracked + } + let turnID = outcome.response.turnID + guard let generation = generationForRoutingIfTracked( + turnID, + allowsOrphanGeneration: allowsOrphanGeneration + ) else { + return .untracked + } + switch generation.phase { + case .active: + break + case .terminalPendingBind(let existing), .finalizing(let existing): + let duplicate = generation.accumulator.compact(outcome) + precondition(existing == duplicate, "A turn generation terminal cannot be replaced.") + return .duplicate + case .terminating: + preconditionFailure("A terminating generation cannot accept a terminal outcome.") + } + + let compactSnapshot = generation.accumulator.compact(outcome) + if orphanGenerations[turnID] === generation { + generation.phase = .terminalPendingBind(compactSnapshot) + generation.relay.finish(with: compactSnapshot) + return .routed + } + + generation.phase = .finalizing(compactSnapshot) + generation.relay.finish(with: compactSnapshot) + if let state = generation.liveState() { + _ = await state.transitionToTerminal(compactSnapshot) + } + if generations[turnID] === generation { + generations.removeValue(forKey: turnID) + } else if case .open = phase { + preconditionFailure("A finalizing turn generation changed identity.") + } + return .routed + } + + package func terminateAll(with termination: CodexConnectionTermination) async { + switch phase { + case .open: + phase = .terminating(termination) + case .terminating(let existing): + precondition(existing == termination, "Turn replay termination cannot be replaced.") + await withCheckedContinuation { continuation in + terminationWaiters.append(continuation) + } + return + case .terminated(let existing): + precondition(existing == termination, "Turn replay termination cannot be replaced.") + return + } + + var statesToTerminate: [ObjectIdentifier: TurnGenerationHandleState] = [:] + var terminalTransitions: [ + ObjectIdentifier: (TurnGenerationHandleState, CompactTurnSnapshot) + ] = [:] + for operation in pendingOperations.values { + if let state = operation.state.value { + statesToTerminate[ObjectIdentifier(state)] = state + } + } + for generation in generations.values { + switch generation.phase { + case .active: + generation.phase = .terminating(termination) + generation.relay.finish(throwing: .connectionTerminated(termination)) + if let state = generation.liveState() { + statesToTerminate[ObjectIdentifier(state)] = state + } + case .finalizing(let compactSnapshot): + if let state = generation.liveState() { + terminalTransitions[ObjectIdentifier(state)] = (state, compactSnapshot) + } + case .terminating(let existing): + precondition(existing == termination) + case .terminalPendingBind: + preconditionFailure("A bound generation cannot be pending bind.") + } + } + for generation in orphanGenerations.values { + switch generation.phase { + case .active: + generation.phase = .terminating(termination) + generation.relay.finish(throwing: .connectionTerminated(termination)) + if let state = generation.liveState() { + statesToTerminate[ObjectIdentifier(state)] = state + } + case .terminalPendingBind(let compactSnapshot): + if let state = generation.liveState() { + terminalTransitions[ObjectIdentifier(state)] = (state, compactSnapshot) + } + case .terminating(let existing): + precondition(existing == termination) + case .finalizing: + preconditionFailure("An orphan generation cannot be finalizing.") + } + } + + for (id, transition) in terminalTransitions { + statesToTerminate.removeValue(forKey: id) + _ = await transition.0.transitionToTerminal(transition.1) + } + for state in statesToTerminate.values { + _ = await state.transitionToTerminated(termination) + } + pendingOperations.removeAll(keepingCapacity: false) + generations.removeAll(keepingCapacity: false) + orphanGenerations.removeAll(keepingCapacity: false) + phase = .terminated(termination) + let waiters = terminationWaiters + terminationWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + package func snapshotForTesting() -> Snapshot { + let postWriteCount = pendingOperations.values.reduce(into: 0) { count, operation in + if operation.token.isWriteAccepted { + count += 1 + } + } + let weakStateCount = generations.values.reduce(into: 0) { count, generation in + if generation.state?.value != nil { + count += 1 + } + } + let terminalOrphanCount = orphanGenerations.values.reduce(into: 0) { + count, generation in + if case .terminalPendingBind = generation.phase { + count += 1 + } + } + let termination: CodexConnectionTermination? + switch phase { + case .open: + termination = nil + case .terminating(let reason), .terminated(let reason): + termination = reason + } + return .init( + pendingOperationCount: pendingOperations.count, + postWritePendingOperationCount: postWriteCount, + activeGenerationCount: generations.count, + orphanGenerationCount: orphanGenerations.count, + terminalOrphanCount: terminalOrphanCount, + weakStateRegistrationCount: weakStateCount, + termination: termination + ) + } + + package func subscriberCountForTesting(turnID: CodexTurnID) -> Int { + (generations[turnID] ?? orphanGenerations[turnID])? + .relay.snapshotForTesting().subscriberCount ?? 0 + } + + private func generationForRoutingIfTracked( + _ turnID: CodexTurnID, + allowsOrphanGeneration: Bool = true + ) -> Generation? { + if let generation = generations[turnID] ?? orphanGenerations[turnID] { + return generation + } + guard allowsOrphanGeneration else { + return nil + } + let unboundPostWriteCount = pendingOperations.values.reduce(into: 0) { + count, operation in + guard operation.token.isWriteAccepted else { + return + } + precondition( + operation.state.value != nil, + "Post-write generation state must remain alive until binding or termination." + ) + count += 1 + } + guard unboundPostWriteCount > 0 else { + return nil + } + precondition( + orphanGenerations.count < unboundPostWriteCount, + "An unbound early turn generation must correspond to a post-write pending operation." + ) + let generation = Generation(turnID: turnID) + orphanGenerations[turnID] = generation + return generation + } + + private func terminalEventsAfterFinalization( + for turnID: CodexTurnID, + state: TurnGenerationHandleState + ) async throws -> TurnReplayEvents { + if let terminalEvents = try await state.terminalEvents() { + return terminalEvents + } + if let generation = generations[turnID] { + guard case .active = generation.phase else { + preconditionFailure("Generation finalization did not publish handle state.") + } + generation.register(state) + return generation.relay.events( + initialSnapshot: generation.accumulator.snapshot + ) + } + preconditionFailure("A live handle has no active turn replay generation.") + } + + private func terminalProgressEventsAfterFinalization( + for turnID: CodexTurnID, + state: TurnGenerationHandleState + ) async throws -> TurnReplayProgressEvents { + if let terminalEvents = try await state.terminalProgressEvents() { + return terminalEvents + } + if let generation = generations[turnID] { + guard case .active = generation.phase else { + preconditionFailure("Generation finalization did not publish handle state.") + } + generation.register(state) + return generation.relay.progressEvents( + initialProgress: generation.accumulator.progress + ) + } + preconditionFailure("A live handle has no active turn replay generation.") + } + + private func requireReservation( + _ reservation: RestoredGenerationReservation, + in generation: Generation + ) { + guard let registeredState = generation.liveState() else { + preconditionFailure("A reserved restored generation must retain its structured scope.") + } + precondition( + registeredState === reservation.state, + "A restore reservation must resolve against its canonical generation state." + ) + precondition( + generation.provisionalRestorationTokens.contains(reservation.token), + "A restored generation reservation may be committed or discarded exactly once." + ) + } + + private func requireFinalizedReservationState( + _ state: TurnGenerationHandleState + ) async { + guard await state.snapshot() != .live else { + preconditionFailure( + "A live restore reservation cannot outlive its replay generation." + ) + } + } + + private func requireOpen() { + guard case .open = phase else { + preconditionFailure("A terminated turn replay store cannot accept new work.") + } + } +} + +private final class WeakTurnGenerationHandleState { + weak var value: TurnGenerationHandleState? + + init(_ value: TurnGenerationHandleState) { + self.value = value + } +} + +private struct TurnReplayAccumulator { + private var snapshotReducer: CodexTurnSnapshotReducer + private(set) var usage: CodexTokenUsage? + private var hasRoutedEvent = false + + init(turnID: CodexTurnID) { + snapshotReducer = .init(turnID: turnID) + } + + var snapshot: CodexTurnSnapshot { + snapshotReducer.snapshot + } + + var progress: CodexReviewProgress { + .running(transcript: .init(items: snapshot.items), usage: usage) + } + + mutating func seed(_ initialSnapshot: CodexTurnSnapshot) { + precondition(initialSnapshot.id == snapshot.id) + guard hasRoutedEvent else { + snapshotReducer.replaceBindingSnapshot(with: initialSnapshot) + return + } + snapshotReducer.merge(initialSnapshot) + } + + mutating func apply(_ event: CodexTurnEvent) { + hasRoutedEvent = true + switch event { + case .started(let turnID): + precondition(turnID == snapshot.id) + snapshotReducer.markStarted() + case .snapshot(let newSnapshot): + precondition(newSnapshot.id == snapshot.id) + snapshotReducer.replace(with: newSnapshot) + case .itemStarted(let item), .itemUpdated(let item), .itemCompleted(let item): + upsert(item) + case .message(let message): + upsert(.init( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + )) + case .messageDelta(let delta): + upsert(requiredCurrentItem(delta.currentItem, eventName: "message delta")) + case .reasoningSummaryPartAdded(let part): + upsert(requiredCurrentItem(part.currentItem, eventName: "reasoning part")) + case .reasoningDelta(let delta): + upsert(requiredCurrentItem(delta.currentItem, eventName: "reasoning delta")) + case .tokenUsageUpdated(let newUsage): + usage = newUsage + case .diagnostic: + break + case .unknown: + break + case .terminal: + preconditionFailure("Terminal events must be compacted by finish(_:).") + } + } + + func compact(_ outcome: CodexTurnOutcome) -> CompactTurnSnapshot { + precondition(outcome.response.turnID == snapshot.id) + var reducer = snapshotReducer + return reducer.finish(finalized(outcome)) + } + + private func finalized(_ outcome: CodexTurnOutcome) -> CodexTurnOutcome { + switch outcome { + case .completed(let response): + .completed(finalized(response)) + case .interrupted(let response): + .interrupted(finalized(response)) + case .failed(let failedTurn): + .failed(.init(response: finalized(failedTurn.response), error: failedTurn.error)) + case .invalidTerminalStatus(let rawStatus, let error, let response): + .invalidTerminalStatus( + rawStatus: rawStatus, + error: error, + response: finalized(response) + ) + } + } + + private func finalized(_ response: CodexResponse) -> CodexResponse { + var response = response + if response.usage == nil { + response.usage = usage + } + return response + } + + private mutating func upsert(_ item: CodexThreadItem) { + snapshotReducer.observe(item) + } + + private func requiredCurrentItem( + _ item: CodexThreadItem?, + eventName: StaticString + ) -> CodexThreadItem { + guard let item else { + preconditionFailure("A reduced \(eventName) must carry its current item.") + } + return item + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestConnectionHarness.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestConnectionHarness.swift new file mode 100644 index 00000000..9288e1d6 --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestConnectionHarness.swift @@ -0,0 +1,126 @@ +import CodexAppServerKit + +package struct CodexAppServerTestConnectionHarness: Sendable { + package let client: AppServerClient + package let router: CodexAppServerNotificationRouter + package let turnReplayStore: TurnReplayStore + package let connection: AppServerConnection + package let supervisor: ConnectionSupervisor + package let lease: AppServerConnectionLease + + package static func start( + transport: any JSONRPC.Transport, + processTerminationToken: ProcessTerminationToken = .init(), + clock: CodexAppServerClock = .init(), + deadlines: CodexAppServer.Configuration.Deadlines = .init(), + deadlineClock: CodexDeadlineClock = .continuous, + handler: CodexAppServerRequestHandler? = nil, + diagnosticHandler: @escaping ServerRequestRegistry.DiagnosticHandler = { _ in } + ) async -> Self { + let connectionCloseAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + deadlines: deadlines, + deadlineClock: deadlineClock, + connectionCloseAction: connectionCloseAction + ) + return await start( + transport: transport, + processTerminationToken: processTerminationToken, + clock: clock, + handler: handler, + diagnosticHandler: diagnosticHandler, + client: client, + connectionCloseAction: connectionCloseAction, + deadlineClock: deadlineClock + ) + } + + package static func start( + transport: any JSONRPC.Transport, + processTerminationToken: ProcessTerminationToken = .init(), + clock: CodexAppServerClock = .init(), + deadlines: CodexAppServer.Configuration.Deadlines = .init(), + deadlineClock: CodexDeadlineClock = .continuous, + overloadRetryDelay: @escaping @Sendable (Int) -> Duration?, + retrySleep: @escaping @Sendable (Duration) async throws -> Void, + handler: CodexAppServerRequestHandler? = nil, + diagnosticHandler: @escaping ServerRequestRegistry.DiagnosticHandler = { _ in } + ) async -> Self { + let connectionCloseAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + deadlines: deadlines, + deadlineClock: deadlineClock, + connectionCloseAction: connectionCloseAction, + overloadRetryDelay: overloadRetryDelay, + retrySleep: retrySleep + ) + return await start( + transport: transport, + processTerminationToken: processTerminationToken, + clock: clock, + handler: handler, + diagnosticHandler: diagnosticHandler, + client: client, + connectionCloseAction: connectionCloseAction, + deadlineClock: deadlineClock + ) + } + + private static func start( + transport: any JSONRPC.Transport, + processTerminationToken: ProcessTerminationToken, + clock: CodexAppServerClock, + handler: CodexAppServerRequestHandler?, + diagnosticHandler: @escaping ServerRequestRegistry.DiagnosticHandler, + client: AppServerClient, + connectionCloseAction: ConnectionCloseAction, + deadlineClock: CodexDeadlineClock + ) async -> Self { + let turnReplayStore = TurnReplayStore() + let threadEventHub = ThreadEventHub() + let router = CodexAppServerNotificationRouter( + client: client, + turnReplayStore: turnReplayStore, + threadEventHub: threadEventHub, + loginRegistry: LoginRegistry(sleep: deadlineClock.sleep) + ) + let connection = AppServerConnection( + transport: transport, + client: client, + router: router, + turnReplayStore: turnReplayStore, + serverRequestHandler: handler + ?? CodexAppServer.Configuration.defaultServerRequestHandler(clock: clock), + serverRequestDiagnosticHandler: diagnosticHandler + ) + let supervisor = ConnectionSupervisor(connection: connection) + connectionCloseAction.bind(to: supervisor) + let lease = AppServerConnectionLease( + supervisor: supervisor, + processTerminationToken: processTerminationToken + ) + await supervisor.start() + return .init( + client: client, + router: router, + turnReplayStore: turnReplayStore, + connection: connection, + supervisor: supervisor, + lease: lease + ) + } + + package func close() async { + await lease.closeConnection() + } + + package var server: CodexAppServer { + CodexAppServer( + client: client, + router: router, + connectionLease: lease + ) + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestNotificationEmitter.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestNotificationEmitter.swift new file mode 100644 index 00000000..7c6aac0e --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestNotificationEmitter.swift @@ -0,0 +1,963 @@ +import CodexAppServerKit +import Foundation + +public enum CodexAppServerTestError: Error, Equatable, LocalizedError, Sendable { + case invalidFixture(String) + + public var errorDescription: String? { + switch self { + case .invalidFixture(let message): + "Invalid Codex app-server test fixture: \(message)" + } + } +} + +public struct CodexAppServerTestItem: Equatable, Sendable { + public enum CommandSource: String, Equatable, Sendable { + case agent + case userShell = "user_shell" + case unifiedExecStartup = "unified_exec_startup" + case unifiedExecInteraction = "unified_exec_interaction" + } + + public enum CommandStatus: String, Equatable, Sendable { + case inProgress + case completed + case failed + case declined + } + + public enum PatchStatus: String, Equatable, Sendable { + case inProgress + case completed + case failed + case declined + } + + public enum MCPStatus: String, Equatable, Sendable { + case inProgress + case completed + case failed + } + + public let domainProjection: CodexThreadItem + package let wireValue: CodexJSONValue + + private init(domainProjection: CodexThreadItem, fields: [String: CodexJSONValue]) { + self.domainProjection = domainProjection + self.wireValue = .object(fields) + } + + public static func userMessage(id: String, text: String) throws -> Self { + try validateID(id) + var fields = baseFields(id: id, type: "userMessage") + fields["content"] = .array([ + .object([ + "type": .string("text"), + "text": .string(text), + "textElements": .array([]), + ]) + ]) + return Self( + domainProjection: .init( + id: id, + kind: .userMessage, + content: .message(.init(id: id, role: .user, text: text)) + ), + fields: fields + ) + } + + public static func agentMessage( + id: String, + text: String, + phase: CodexMessagePhase? = nil + ) throws -> Self { + try validateID(id) + var fields = baseFields(id: id, type: "agentMessage") + fields["text"] = .string(text) + if let phase { + fields["phase"] = .string(phase.rawValue) + } + return Self( + domainProjection: .init( + id: id, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, phase: phase, text: text)) + ), + fields: fields + ) + } + + public static func plan(id: String, text: String) throws -> Self { + try validateID(id) + var fields = baseFields(id: id, type: "plan") + fields["text"] = .string(text) + return Self( + domainProjection: .init(id: id, kind: .plan, content: .plan(text)), + fields: fields + ) + } + + public static func reasoning( + id: String, + summary: [String] = [], + content: [String] = [] + ) throws -> Self { + try validateID(id) + var fields = baseFields(id: id, type: "reasoning") + fields["summary"] = .array(summary.map(CodexJSONValue.string)) + fields["content"] = .array(content.map(CodexJSONValue.string)) + return Self( + domainProjection: .init( + id: id, + kind: .reasoning, + content: .reasoning(.init(summary: summary, content: content)) + ), + fields: fields + ) + } + + public static func commandExecution( + id: String, + command: String, + cwd: URL, + processID: String? = nil, + source: CommandSource = .agent, + status: CommandStatus, + aggregatedOutput: String? = nil, + exitCode: Int32? = nil, + duration: Duration? = nil + ) throws -> Self { + try validateID(id) + try validateFileURL(cwd, field: "cwd") + guard command.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("command must not be empty") + } + var fields = baseFields(id: id, type: "commandExecution") + fields["command"] = .string(command) + fields["cwd"] = .string(cwd.path) + fields["commandActions"] = .array([]) + fields["source"] = .string(source.rawValue) + fields["status"] = .string(status.rawValue) + if let processID { fields["processId"] = .string(processID) } + if let aggregatedOutput { fields["aggregatedOutput"] = .string(aggregatedOutput) } + if let exitCode { fields["exitCode"] = .int(Int(exitCode)) } + if let milliseconds = duration?.millisecondsForTesting { + fields["durationMs"] = .int(milliseconds) + } + return Self( + domainProjection: .init( + id: id, + kind: .commandExecution, + content: .command(.init( + command: command, + cwd: cwd.path, + output: aggregatedOutput, + exitCode: exitCode.map(Int.init), + status: status.turnStatus, + duration: duration, + processID: processID, + source: .init(rawValue: source.rawValue) + )) + ), + fields: fields + ) + } + + public static func fileChange( + id: String, + changes: [CodexFileUpdateChange], + status: PatchStatus + ) throws -> Self { + try validateID(id) + guard changes.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("file changes must not be empty") + } + var fields = baseFields(id: id, type: "fileChange") + fields["status"] = .string(status.rawValue) + fields["changes"] = .array(changes.map(\.wireValueForTesting)) + return Self( + domainProjection: .init( + id: id, + kind: .fileChange, + content: .fileChange(.init( + path: changes.first?.path, + output: changes.map(\.diff).joined(separator: "\n"), + status: status.turnStatus + )) + ), + fields: fields + ) + } + + public static func mcpToolCall( + id: String, + server: String, + tool: String, + status: MCPStatus, + arguments: CodexJSONValue = .object([:]), + resultContent: [CodexJSONValue]? = nil, + structuredContent: CodexJSONValue? = nil, + resultMetadata: CodexJSONValue? = nil, + errorMessage: String? = nil, + duration: Duration? = nil + ) throws -> Self { + try validateID(id) + guard server.isEmpty == false, tool.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("MCP server and tool must not be empty") + } + var fields = baseFields(id: id, type: "mcpToolCall") + fields["server"] = .string(server) + fields["tool"] = .string(tool) + fields["status"] = .string(status.rawValue) + fields["arguments"] = arguments + let result: CodexJSONValue? + if resultContent != nil || structuredContent != nil || resultMetadata != nil { + result = .object([ + "content": .array(resultContent ?? []), + "structuredContent": structuredContent ?? .null, + "_meta": resultMetadata ?? .null, + ]) + fields["result"] = result + } else { + result = nil + } + if let errorMessage { + fields["error"] = .object(["message": .string(errorMessage)]) + } + if let milliseconds = duration?.millisecondsForTesting { + fields["durationMs"] = .int(milliseconds) + } + return Self( + domainProjection: .init( + id: id, + kind: .mcpToolCall, + content: .toolCall(.init( + server: server, + name: tool, + arguments: arguments.jsonStringForTesting, + result: result?.displayTextForTesting, + error: errorMessage, + status: status.turnStatus + )) + ), + fields: fields + ) + } + + public static func enteredReviewMode(id: String, review: String) throws -> Self { + try reviewMarker(id: id, review: review, kind: .enteredReviewMode) + } + + public static func exitedReviewMode(id: String, review: String) throws -> Self { + try reviewMarker(id: id, review: review, kind: .exitedReviewMode) + } + + public static func contextCompaction(id: String) throws -> Self { + try validateID(id) + return Self( + domainProjection: .init(id: id, kind: .contextCompaction, content: .contextCompaction(nil)), + fields: baseFields(id: id, type: "contextCompaction") + ) + } + + private static func reviewMarker( + id: String, + review: String, + kind: CodexThreadItem.Kind + ) throws -> Self { + try validateID(id) + var fields = baseFields(id: id, type: kind.rawValue) + fields["review"] = .string(review) + return Self( + domainProjection: .init(id: id, kind: kind, content: .log(review)), + fields: fields + ) + } + + private static func baseFields(id: String, type: String) -> [String: CodexJSONValue] { + ["id": .string(id), "type": .string(type)] + } + + private static func validateID(_ id: String) throws { + guard id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("item id must not be empty") + } + } + + private static func validateFileURL(_ url: URL, field: String) throws { + guard url.isFileURL, url.path.hasPrefix("/") else { + throw CodexAppServerTestError.invalidFixture("\(field) must be an absolute file URL") + } + } +} + +public enum CodexAppServerTestAuthMode: Equatable, Sendable { + case apiKey + case chatGPT + case chatGPTAuthTokens + case headers + case agentIdentity + case personalAccessToken + case bedrockAPIKey + + package var wireValue: String { + switch self { + case .apiKey: "apikey" + case .chatGPT: "chatgpt" + case .chatGPTAuthTokens: "chatGPTAuthTokens" + case .headers: "headers" + case .agentIdentity: "agentIdentity" + case .personalAccessToken: "personalAccessToken" + case .bedrockAPIKey: "bedrockApiKey" + } + } +} + +public enum CodexAppServerTestPlanType: Equatable, Sendable { + case free + case go + case plus + case pro + case proLite + case team + case selfServeBusinessUsageBased + case business + case enterpriseCBPUsageBased + case enterprise + case edu + case unknown + + package var wireValue: String { + switch self { + case .free: "free" + case .go: "go" + case .plus: "plus" + case .pro: "pro" + case .proLite: "prolite" + case .team: "team" + case .selfServeBusinessUsageBased: "self_serve_business_usage_based" + case .business: "business" + case .enterpriseCBPUsageBased: "enterprise_cbp_usage_based" + case .enterprise: "enterprise" + case .edu: "edu" + case .unknown: "unknown" + } + } +} + +public struct CodexAppServerTestAccountUpdate: Equatable, Sendable { + public var authMode: CodexAppServerTestAuthMode? + public var planType: CodexAppServerTestPlanType? + + public init( + authMode: CodexAppServerTestAuthMode?, + planType: CodexAppServerTestPlanType? + ) { + self.authMode = authMode + self.planType = planType + } +} + +public struct CodexAppServerTestRateLimitSnapshot: Equatable, Sendable { + public enum ReachedType: Equatable, Sendable { + case rateLimitReached + case workspaceOwnerCreditsDepleted + case workspaceMemberCreditsDepleted + case workspaceOwnerUsageLimitReached + case workspaceMemberUsageLimitReached + + fileprivate var wireValue: String { + switch self { + case .rateLimitReached: "rate_limit_reached" + case .workspaceOwnerCreditsDepleted: "workspace_owner_credits_depleted" + case .workspaceMemberCreditsDepleted: "workspace_member_credits_depleted" + case .workspaceOwnerUsageLimitReached: "workspace_owner_usage_limit_reached" + case .workspaceMemberUsageLimitReached: "workspace_member_usage_limit_reached" + } + } + } + + public struct Window: Equatable, Sendable { + public var usedPercent: Int32 + public var windowDurationMinutes: Int64? + public var resetsAtUnixSeconds: Int64? + + public init( + usedPercent: Int32, + windowDurationMinutes: Int64?, + resetsAtUnixSeconds: Int64? + ) { + self.usedPercent = usedPercent + self.windowDurationMinutes = windowDurationMinutes + self.resetsAtUnixSeconds = resetsAtUnixSeconds + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "usedPercent": .int(Int(usedPercent)), + "windowDurationMins": windowDurationMinutes.map { .int(Int($0)) } ?? .null, + "resetsAt": resetsAtUnixSeconds.map { .int(Int($0)) } ?? .null, + ]) + } + } + + public struct Credits: Equatable, Sendable { + public var hasCredits: Bool + public var unlimited: Bool + public var balance: String? + + public init(hasCredits: Bool, unlimited: Bool, balance: String?) { + self.hasCredits = hasCredits + self.unlimited = unlimited + self.balance = balance + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "hasCredits": .bool(hasCredits), + "unlimited": .bool(unlimited), + "balance": balance.map(CodexJSONValue.string) ?? .null, + ]) + } + } + + public struct SpendControl: Equatable, Sendable { + public var limit: String + public var used: String + public var remainingPercent: Int32 + public var resetsAtUnixSeconds: Int64 + + public init( + limit: String, + used: String, + remainingPercent: Int32, + resetsAtUnixSeconds: Int64 + ) throws { + guard limit.isEmpty == false, used.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "rate-limit spend-control values must not be empty" + ) + } + self.limit = limit + self.used = used + self.remainingPercent = remainingPercent + self.resetsAtUnixSeconds = resetsAtUnixSeconds + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "limit": .string(limit), + "used": .string(used), + "remainingPercent": .int(Int(remainingPercent)), + "resetsAt": .int(Int(resetsAtUnixSeconds)), + ]) + } + } + + public var limitID: String? + public var limitName: String? + public var primary: Window? + public var secondary: Window? + public var credits: Credits? + public var individualLimit: SpendControl? + public var planType: CodexAppServerTestPlanType? + public var reachedType: ReachedType? + + public init( + limitID: String?, + limitName: String?, + primary: Window?, + secondary: Window?, + credits: Credits?, + individualLimit: SpendControl?, + planType: CodexAppServerTestPlanType?, + reachedType: ReachedType? + ) throws { + if let limitID, limitID.isEmpty { + throw CodexAppServerTestError.invalidFixture("rate-limit id must not be empty") + } + self.limitID = limitID + self.limitName = limitName + self.primary = primary + self.secondary = secondary + self.credits = credits + self.individualLimit = individualLimit + self.planType = planType + self.reachedType = reachedType + } + + package var wireValue: CodexJSONValue { + .object([ + "limitId": limitID.map(CodexJSONValue.string) ?? .null, + "limitName": limitName.map(CodexJSONValue.string) ?? .null, + "primary": primary?.wireValue ?? .null, + "secondary": secondary?.wireValue ?? .null, + "credits": credits?.wireValue ?? .null, + "individualLimit": individualLimit?.wireValue ?? .null, + "planType": planType.map { .string($0.wireValue) } ?? .null, + "rateLimitReachedType": reachedType.map { .string($0.wireValue) } ?? .null, + ]) + } +} + +public struct CodexAppServerTestRateLimitsUpdate: Equatable, Sendable { + public var snapshot: CodexAppServerTestRateLimitSnapshot + + public init(snapshot: CodexAppServerTestRateLimitSnapshot) { + self.snapshot = snapshot + } +} + +public enum CodexAppServerTestLoginCompletion: Equatable, Sendable { + case succeeded + case failed(message: String?) +} + +public actor CodexAppServerTestNotificationEmitter { + private var nextFixtureTimestampMilliseconds = 4_102_444_800_000 + private let transport: CodexAppServerTestTransport + + public init(transport: CodexAppServerTestTransport) { + self.transport = transport + } + + public func emitItemStarted( + threadID: CodexThreadID, + turnID: CodexTurnID, + item: CodexAppServerTestItem + ) async throws { + try await emitItem( + method: "item/started", + timestampKey: "startedAtMs", + threadID: threadID, + turnID: turnID, + item: item + ) + } + + public func emitItemCompleted( + threadID: CodexThreadID, + turnID: CodexTurnID, + item: CodexAppServerTestItem + ) async throws { + try await emitItem( + method: "item/completed", + timestampKey: "completedAtMs", + threadID: threadID, + turnID: turnID, + item: item + ) + } + + public func emitAgentMessageDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws { + try await emitDelta( + method: "item/agentMessage/delta", + threadID: threadID, + turnID: turnID, + itemID: itemID, + delta: delta, + additionalFields: ["phase": .string("final_answer")] + ) + } + + public func emitPlanDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws { + try await emitDelta( + method: "item/plan/delta", + threadID: threadID, + turnID: turnID, + itemID: itemID, + delta: delta + ) + } + + public func emitReasoningSummaryTextDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + summaryIndex: Int64, + delta: String + ) async throws { + try await emitDelta( + method: "item/reasoning/summaryTextDelta", + threadID: threadID, + turnID: turnID, + itemID: itemID, + delta: delta, + additionalFields: ["summaryIndex": .int(Int(summaryIndex))] + ) + } + + public func emitReasoningSummaryPartAdded( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + summaryIndex: Int64 + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: itemID) + guard summaryIndex >= 0 else { + throw CodexAppServerTestError.invalidFixture("reasoning summary index must not be negative") + } + try await transport.emitServerNotification( + method: "item/reasoning/summaryPartAdded", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "turnId": .string(turnID.rawValue), + "itemId": .string(itemID), + "summaryIndex": .int(Int(summaryIndex)), + ]) + ) + } + + public func emitReasoningTextDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + contentIndex: Int64, + delta: String + ) async throws { + try await emitDelta( + method: "item/reasoning/textDelta", + threadID: threadID, + turnID: turnID, + itemID: itemID, + delta: delta, + additionalFields: ["contentIndex": .int(Int(contentIndex))] + ) + } + + public func emitCommandExecutionOutputDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws { + try await emitDelta( + method: "item/commandExecution/outputDelta", + threadID: threadID, + turnID: turnID, + itemID: itemID, + delta: delta + ) + } + + public func emitMCPToolCallProgress( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + message: String + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: itemID) + guard message.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("MCP progress message must not be empty") + } + try await transport.emitServerNotification( + method: "item/mcpToolCall/progress", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "turnId": .string(turnID.rawValue), + "itemId": .string(itemID), + "message": .string(message), + ]) + ) + } + + public func emitFileChangePatchUpdated( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + changes: [CodexFileUpdateChange] + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: itemID) + guard changes.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("file changes must not be empty") + } + try await transport.emitServerNotification( + method: "item/fileChange/patchUpdated", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "turnId": .string(turnID.rawValue), + "itemId": .string(itemID), + "changes": .array(changes.map(\.wireValueForTesting)), + ]) + ) + } + + public func emitTurnCompleted( + threadID: CodexThreadID, + turn: CodexAppServerTestTurn + ) async throws { + guard threadID.rawValue.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("thread id must not be empty") + } + switch turn.snapshot.state { + case .completed, .interrupted, .failed: + break + case .inProgress, .unknown: + throw CodexAppServerTestError.invalidFixture( + "turn/completed requires a completed, interrupted, or failed turn" + ) + } + try await transport.emitServerNotification( + method: "turn/completed", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "turn": turn.wireValue, + ]) + ) + } + + public func emitThreadStatusChanged( + threadID: CodexThreadID, + status: CodexThreadStatus + ) async throws { + guard threadID.rawValue.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("thread id must not be empty") + } + var statusFields: [String: CodexJSONValue] = [ + "type": .string(status.rawValue), + ] + if case .active(let flags) = status { + statusFields["activeFlags"] = .array(flags.map { .string($0.rawValue) }) + } + try await transport.emitServerNotification( + method: "thread/status/changed", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "status": .object(statusFields), + ]) + ) + } + + public func emitError( + threadID: CodexThreadID, + turnID: CodexTurnID, + error: CodexTurnError, + willRetry: Bool + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: "error") + guard error.message.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("turn error message must not be empty") + } + try await transport.emitServerNotification( + method: "error", + params: CodexJSONValue.object([ + "threadId": .string(threadID.rawValue), + "turnId": .string(turnID.rawValue), + "error": error.wireValueForTesting, + "willRetry": .bool(willRetry), + ]) + ) + } + + public func emitAccountChanged( + _ update: CodexAppServerTestAccountUpdate + ) async throws { + try await transport.emitServerNotification( + method: "account/updated", + params: CodexJSONValue.object([ + "authMode": update.authMode.map { .string($0.wireValue) } ?? .null, + "planType": update.planType.map { .string($0.wireValue) } ?? .null, + ]) + ) + } + + public func emitRateLimitsUpdated( + _ update: CodexAppServerTestRateLimitsUpdate + ) async throws { + try await transport.emitServerNotification( + method: "account/rateLimits/updated", + params: CodexJSONValue.object([ + "rateLimits": update.snapshot.wireValue, + ]) + ) + } + + public func emitLoginCompleted( + loginID: CodexLoginHandle.ID, + completion: CodexAppServerTestLoginCompletion + ) async throws { + guard loginID.rawValue.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("login id must not be empty") + } + let success: Bool + let error: CodexJSONValue + switch completion { + case .succeeded: + success = true + error = .null + case .failed(let message): + success = false + error = message.map(CodexJSONValue.string) ?? .null + } + try await transport.emitServerNotification( + method: "account/login/completed", + params: CodexJSONValue.object([ + "loginId": .string(loginID.rawValue), + "success": .bool(success), + "error": error, + ]) + ) + } + + private func emitItem( + method: String, + timestampKey: String, + threadID: CodexThreadID, + turnID: CodexTurnID, + item: CodexAppServerTestItem + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: item.domainProjection.id) + let timestampMilliseconds = nextFixtureTimestampMilliseconds + nextFixtureTimestampMilliseconds += 1 + let fields: [String: CodexJSONValue] = [ + "threadId": .string(threadID.rawValue), + "turnId": .string(turnID.rawValue), + timestampKey: .int(timestampMilliseconds), + "item": item.wireValue, + ] + try await transport.emitServerNotification( + method: method, + params: CodexJSONValue.object(fields) + ) + } + + private func emitDelta( + method: String, + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String, + additionalFields: [String: CodexJSONValue] = [:] + ) async throws { + try validateContext(threadID: threadID, turnID: turnID, itemID: itemID) + guard delta.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("delta must not be empty") + } + var fields = additionalFields + fields["threadId"] = .string(threadID.rawValue) + fields["turnId"] = .string(turnID.rawValue) + fields["itemId"] = .string(itemID) + fields["delta"] = .string(delta) + try await transport.emitServerNotification( + method: method, + params: CodexJSONValue.object(fields) + ) + } + + private func validateContext( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String + ) throws { + guard threadID.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + turnID.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + itemID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "thread, turn, and item ids must not be empty or whitespace" + ) + } + } +} + +private extension CodexAppServerTestItem.CommandStatus { + var turnStatus: CodexTurnStatus { + switch self { + case .inProgress: .inProgress + case .completed: .completed + case .failed, .declined: .failed + } + } +} + +private extension CodexAppServerTestItem.MCPStatus { + var turnStatus: CodexTurnStatus { + switch self { + case .inProgress: .inProgress + case .completed: .completed + case .failed: .failed + } + } +} + +private extension CodexAppServerTestItem.PatchStatus { + var turnStatus: CodexTurnStatus { + switch self { + case .inProgress: .inProgress + case .completed: .completed + case .failed, .declined: .failed + } + } +} + +private extension CodexFileUpdateChange { + var wireValueForTesting: CodexJSONValue { + var kindFields: [String: CodexJSONValue] + switch kind { + case .add: + kindFields = ["type": .string("add")] + case .delete: + kindFields = ["type": .string("delete")] + case .update(let movePath): + kindFields = ["type": .string("update")] + if let movePath { + kindFields["move_path"] = .string(movePath) + } + } + return .object([ + "path": .string(path), + "kind": .object(kindFields), + "diff": .string(diff), + ]) + } +} + +private extension Duration { + var millisecondsForTesting: Int? { + let components = self.components + let milliseconds = components.seconds * 1_000 + + components.attoseconds / 1_000_000_000_000_000 + guard milliseconds >= 0, milliseconds <= Int.max else { + return nil + } + return Int(milliseconds) + } +} + +private extension CodexJSONValue { + var displayTextForTesting: String? { + switch self { + case .string(let value): value + case .int(let value): String(value) + case .double(let value): String(value) + case .bool(let value): value ? "true" : "false" + case .object(let value): + value["displayText"]?.displayTextForTesting + ?? value["text"]?.displayTextForTesting + ?? value["message"]?.displayTextForTesting + ?? jsonStringForTesting + case .array: + jsonStringForTesting + case .null: + nil + } + } + + var jsonStringForTesting: String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestResponseFixtures.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestResponseFixtures.swift new file mode 100644 index 00000000..b2f44a9e --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestResponseFixtures.swift @@ -0,0 +1,716 @@ +import CodexAppServerKit +import Foundation + +public struct CodexAppServerTestModel: Equatable, Sendable { + public enum InputModality: Equatable, Sendable { + case text + case image + + fileprivate var wireValue: String { + switch self { + case .text: "text" + case .image: "image" + } + } + } + + public struct UpgradeInfo: Equatable, Sendable { + public var model: String + public var upgradeCopy: String? + public var modelLink: String? + public var migrationMarkdown: String? + + public init( + model: String, + upgradeCopy: String? = nil, + modelLink: String? = nil, + migrationMarkdown: String? = nil + ) throws { + try requireNonEmpty(model, field: "upgrade model") + self.model = model + self.upgradeCopy = upgradeCopy + self.modelLink = modelLink + self.migrationMarkdown = migrationMarkdown + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "model": .string(model), + "upgradeCopy": upgradeCopy.map(CodexJSONValue.string) ?? .null, + "modelLink": modelLink.map(CodexJSONValue.string) ?? .null, + "migrationMarkdown": migrationMarkdown.map(CodexJSONValue.string) ?? .null, + ]) + } + } + + public struct AvailabilityNUX: Equatable, Sendable { + public var message: String + + public init(message: String) { + self.message = message + } + + fileprivate var wireValue: CodexJSONValue { + .object(["message": .string(message)]) + } + } + + public struct ServiceTier: Equatable, Sendable { + public var id: String + public var name: String + public var description: String + + public init(id: String, name: String, description: String) throws { + try requireNonEmpty(id, field: "service-tier id") + try requireNonEmpty(name, field: "service-tier name") + self.id = id + self.name = name + self.description = description + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "id": .string(id), + "name": .string(name), + "description": .string(description), + ]) + } + } + + private let id: String + private let model: String + private let upgrade: String? + private let upgradeInfo: UpgradeInfo? + private let availabilityNUX: AvailabilityNUX? + private let displayName: String + private let modelDescription: String + private let hidden: Bool + private let supportedReasoningEfforts: [CodexModel.ReasoningOption] + private let defaultReasoningEffort: CodexReasoningEffort + private let inputModalities: [InputModality] + private let supportsPersonality: Bool + private let additionalSpeedTiers: [String] + private let serviceTiers: [ServiceTier] + private let defaultServiceTier: String? + private let isDefault: Bool + + public init( + id: String, + model: String, + upgrade: String?, + upgradeInfo: UpgradeInfo?, + availabilityNUX: AvailabilityNUX?, + displayName: String, + description: String, + hidden: Bool, + supportedReasoningEfforts: [CodexModel.ReasoningOption], + defaultReasoningEffort: CodexReasoningEffort, + inputModalities: [InputModality], + supportsPersonality: Bool, + additionalSpeedTiers: [String], + serviceTiers: [ServiceTier], + defaultServiceTier: String?, + isDefault: Bool + ) throws { + try requireNonEmpty(id, field: "model id") + try requireNonEmpty(model, field: "model wire name") + try requireNonEmpty(displayName, field: "model display name") + guard inputModalities.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "model input modalities must not be empty" + ) + } + if let defaultServiceTier, + serviceTiers.contains(where: { $0.id == defaultServiceTier }) == false + { + throw CodexAppServerTestError.invalidFixture( + "default service tier must reference a declared service tier" + ) + } + self.id = id + self.model = model + self.upgrade = upgrade + self.upgradeInfo = upgradeInfo + self.availabilityNUX = availabilityNUX + self.displayName = displayName + self.modelDescription = description + self.hidden = hidden + self.supportedReasoningEfforts = supportedReasoningEfforts + self.defaultReasoningEffort = defaultReasoningEffort + self.inputModalities = inputModalities + self.supportsPersonality = supportsPersonality + self.additionalSpeedTiers = additionalSpeedTiers + self.serviceTiers = serviceTiers + self.defaultServiceTier = defaultServiceTier + self.isDefault = isDefault + } + + public var domainProjection: CodexModel { + guard let data = try? JSONEncoder().encode(wireValue), + let model = try? JSONDecoder().decode(CodexModel.self, from: data) + else { + preconditionFailure("Validated model fixture failed to decode.") + } + return model + } + + package var wireValue: CodexJSONValue { + .object([ + "id": .string(id), + "model": .string(model), + "upgrade": upgrade.map(CodexJSONValue.string) ?? .null, + "upgradeInfo": upgradeInfo?.wireValue ?? .null, + "availabilityNux": availabilityNUX?.wireValue ?? .null, + "displayName": .string(displayName), + "description": .string(modelDescription), + "hidden": .bool(hidden), + "supportedReasoningEfforts": .array(supportedReasoningEfforts.map { + .object([ + "reasoningEffort": .string($0.reasoningEffort.rawValue), + "description": .string($0.description), + ]) + }), + "defaultReasoningEffort": .string(defaultReasoningEffort.rawValue), + "inputModalities": .array(inputModalities.map { .string($0.wireValue) }), + "supportsPersonality": .bool(supportsPersonality), + "additionalSpeedTiers": .array(additionalSpeedTiers.map(CodexJSONValue.string)), + "serviceTiers": .array(serviceTiers.map(\.wireValue)), + "defaultServiceTier": defaultServiceTier.map(CodexJSONValue.string) ?? .null, + "isDefault": .bool(isDefault), + ]) + } +} + +public struct CodexAppServerTestModelPage: Equatable, Sendable { + public var models: [CodexAppServerTestModel] + public var nextCursor: String? + + public init(models: [CodexAppServerTestModel], nextCursor: String? = nil) { + self.models = models + self.nextCursor = nextCursor + } + + package var wireValue: CodexJSONValue { + .object([ + "data": .array(models.map(\.wireValue)), + "nextCursor": nextCursor.map(CodexJSONValue.string) ?? .null, + ]) + } +} + +public enum CodexAppServerTestBedrockCredentialSource: Equatable, Sendable { + case codexManaged + case awsManaged + + fileprivate var wireValue: String { + switch self { + case .codexManaged: "codexManaged" + case .awsManaged: "awsManaged" + } + } +} + +public struct CodexAppServerTestAccount: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case apiKey + case chatGPT(email: String?, planType: CodexAppServerTestPlanType) + case amazonBedrock(credentialSource: CodexAppServerTestBedrockCredentialSource) + } + + private let kind: Kind + + public init(kind: Kind) throws { + if case .chatGPT(let email?, _) = kind, email.isEmpty { + throw CodexAppServerTestError.invalidFixture("account email must not be empty") + } + self.kind = kind + } + + public var domainProjection: CodexAccount { + guard let data = try? JSONEncoder().encode(wireValue), + let snapshot = try? JSONDecoder().decode( + AppServerAPI.Account.Snapshot.self, + from: data + ) + else { + preconditionFailure("Validated account fixture failed to decode.") + } + return CodexAppServer.account(from: snapshot) + } + + package var wireValue: CodexJSONValue { + switch kind { + case .apiKey: + .object(["type": .string("apiKey")]) + case .chatGPT(let email, let planType): + .object([ + "type": .string("chatgpt"), + "email": email.map(CodexJSONValue.string) ?? .null, + "planType": .string(planType.wireValue), + ]) + case .amazonBedrock(let credentialSource): + .object([ + "type": .string("amazonBedrock"), + "credentialSource": .string(credentialSource.wireValue), + ]) + } + } +} + +public struct CodexAppServerTestRateLimitsResponse: Equatable, Sendable { + public enum ResetType: Equatable, Sendable { + case codexRateLimits + case unknown + + fileprivate var wireValue: String { + switch self { + case .codexRateLimits: "codex_rate_limits" + case .unknown: "unknown" + } + } + } + + public enum ResetCreditStatus: Equatable, Sendable { + case available + case redeeming + case redeemed + case unknown + + fileprivate var wireValue: String { + switch self { + case .available: "available" + case .redeeming: "redeeming" + case .redeemed: "redeemed" + case .unknown: "unknown" + } + } + } + + public struct ResetCredit: Equatable, Sendable { + public var id: String + public var resetType: ResetType + public var status: ResetCreditStatus + public var grantedAtUnixSeconds: Int64 + public var expiresAtUnixSeconds: Int64? + public var title: String? + public var description: String? + + public init( + id: String, + resetType: ResetType, + status: ResetCreditStatus, + grantedAtUnixSeconds: Int64, + expiresAtUnixSeconds: Int64?, + title: String?, + description: String? + ) throws { + try requireNonEmpty(id, field: "reset-credit id") + self.id = id + self.resetType = resetType + self.status = status + self.grantedAtUnixSeconds = grantedAtUnixSeconds + self.expiresAtUnixSeconds = expiresAtUnixSeconds + self.title = title + self.description = description + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "id": .string(id), + "resetType": .string(resetType.wireValue), + "status": .string(status.wireValue), + "grantedAt": .int(Int(grantedAtUnixSeconds)), + "expiresAt": expiresAtUnixSeconds.map { .int(Int($0)) } ?? .null, + "title": title.map(CodexJSONValue.string) ?? .null, + "description": description.map(CodexJSONValue.string) ?? .null, + ]) + } + } + + public struct ResetCreditsSummary: Equatable, Sendable { + public var availableCount: Int64 + public var credits: [ResetCredit]? + + public init(availableCount: Int64, credits: [ResetCredit]?) throws { + guard availableCount >= 0 else { + throw CodexAppServerTestError.invalidFixture( + "reset-credit count must not be negative" + ) + } + self.availableCount = availableCount + self.credits = credits + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "availableCount": .int(Int(availableCount)), + "credits": credits.map { .array($0.map(\.wireValue)) } ?? .null, + ]) + } + } + + private let primarySnapshot: CodexAppServerTestRateLimitSnapshot + private let snapshotsByLimitID: [String: CodexAppServerTestRateLimitSnapshot]? + private let resetCredits: ResetCreditsSummary? + + public init( + primarySnapshot: CodexAppServerTestRateLimitSnapshot, + snapshotsByLimitID: [String: CodexAppServerTestRateLimitSnapshot]?, + resetCredits: ResetCreditsSummary? + ) throws { + if let snapshotsByLimitID, + snapshotsByLimitID.keys.contains(where: \.isEmpty) + { + throw CodexAppServerTestError.invalidFixture( + "rate-limit map keys must not be empty" + ) + } + self.primarySnapshot = primarySnapshot + self.snapshotsByLimitID = snapshotsByLimitID + self.resetCredits = resetCredits + } + + public var domainProjection: CodexRateLimits { + let decoder = JSONDecoder() + guard let primaryData = try? JSONEncoder().encode(primarySnapshot.wireValue), + let primary = try? decoder.decode( + AppServerAPI.Account.RateLimits.Snapshot.self, + from: primaryData + ) + else { + preconditionFailure("Validated rate-limit fixture failed to decode.") + } + let snapshots = snapshotsByLimitID.map { values in + values.reduce(into: [String: AppServerAPI.Account.RateLimits.Snapshot]()) { + result, pair in + guard let data = try? JSONEncoder().encode(pair.value.wireValue), + let value = try? decoder.decode( + AppServerAPI.Account.RateLimits.Snapshot.self, + from: data + ) + else { + preconditionFailure("Validated rate-limit fixture failed to decode.") + } + result[pair.key] = value + } + } + return CodexRateLimits(appServer: .init( + rateLimits: primary, + rateLimitsByLimitID: snapshots + )) + } + + package var wireValue: CodexJSONValue { + .object([ + "rateLimits": primarySnapshot.wireValue, + "rateLimitsByLimitId": snapshotsByLimitID.map { snapshots in + .object(snapshots.mapValues(\.wireValue)) + } ?? .null, + "resetCredits": resetCredits?.wireValue ?? .null, + ]) + } +} + +public enum CodexAppServerTestConfigurationLayerSource: Equatable, Sendable { + case mdm(domain: String, key: String) + case system(file: URL) + case enterpriseManaged(id: String, name: String) + case user(file: URL, profile: String?) + case project(dotCodexFolder: URL) + case sessionFlags + case legacyManagedConfigTomlFromFile(file: URL) + case legacyManagedConfigTomlFromMdm + + fileprivate var wireValue: CodexJSONValue { + switch self { + case .mdm(let domain, let key): + .object([ + "type": .string("mdm"), + "domain": .string(domain), + "key": .string(key), + ]) + case .system(let file): + .object([ + "type": .string("system"), + "file": .string(file.path), + ]) + case .enterpriseManaged(let id, let name): + .object([ + "type": .string("enterpriseManaged"), + "id": .string(id), + "name": .string(name), + ]) + case .user(let file, let profile): + .object([ + "type": .string("user"), + "file": .string(file.path), + "profile": profile.map(CodexJSONValue.string) ?? .null, + ]) + case .project(let dotCodexFolder): + .object([ + "type": .string("project"), + "dotCodexFolder": .string(dotCodexFolder.path), + ]) + case .sessionFlags: + .object(["type": .string("sessionFlags")]) + case .legacyManagedConfigTomlFromFile(let file): + .object([ + "type": .string("legacyManagedConfigTomlFromFile"), + "file": .string(file.path), + ]) + case .legacyManagedConfigTomlFromMdm: + .object(["type": .string("legacyManagedConfigTomlFromMdm")]) + } + } + + fileprivate func validate() throws { + switch self { + case .mdm(let domain, let key): + try requireNonEmpty(domain, field: "MDM domain") + try requireNonEmpty(key, field: "MDM key") + case .system(let file), .legacyManagedConfigTomlFromFile(let file): + try requireAbsoluteFileURL(file, field: "configuration layer file") + case .enterpriseManaged(let id, let name): + try requireNonEmpty(id, field: "enterprise layer id") + try requireNonEmpty(name, field: "enterprise layer name") + case .user(let file, let profile): + try requireAbsoluteFileURL(file, field: "user configuration file") + if let profile { + try requireNonEmpty(profile, field: "configuration profile") + } + case .project(let dotCodexFolder): + try requireAbsoluteFileURL(dotCodexFolder, field: "project .codex folder") + case .sessionFlags, .legacyManagedConfigTomlFromMdm: + break + } + } +} + +public struct CodexAppServerTestConfigurationLayerMetadata: Equatable, Sendable { + public var source: CodexAppServerTestConfigurationLayerSource + public var version: String + + public init( + source: CodexAppServerTestConfigurationLayerSource, + version: String + ) throws { + try source.validate() + try requireNonEmpty(version, field: "configuration layer version") + self.source = source + self.version = version + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "name": source.wireValue, + "version": .string(version), + ]) + } +} + +public struct CodexAppServerTestConfigurationLayer: Equatable, Sendable { + public var metadata: CodexAppServerTestConfigurationLayerMetadata + public var configuration: CodexJSONValue + public var disabledReason: String? + + public init( + metadata: CodexAppServerTestConfigurationLayerMetadata, + configuration: CodexJSONValue, + disabledReason: String? = nil + ) throws { + guard case .object = configuration else { + throw CodexAppServerTestError.invalidFixture( + "configuration layer value must be a JSON object" + ) + } + if let disabledReason { + try requireNonEmpty(disabledReason, field: "configuration layer disabled reason") + } + self.metadata = metadata + self.configuration = configuration + self.disabledReason = disabledReason + } + + fileprivate var wireValue: CodexJSONValue { + var fields: [String: CodexJSONValue] = [ + "name": metadata.source.wireValue, + "version": .string(metadata.version), + "config": configuration, + ] + if let disabledReason { + fields["disabledReason"] = .string(disabledReason) + } + return .object(fields) + } +} + +public struct CodexAppServerTestConfigurationReadResult: Equatable, Sendable { + public var configuration: CodexConfiguration + public var origins: [String: CodexAppServerTestConfigurationLayerMetadata] + public var layers: [CodexAppServerTestConfigurationLayer]? + + public init( + configuration: CodexConfiguration, + origins: [String: CodexAppServerTestConfigurationLayerMetadata], + layers: [CodexAppServerTestConfigurationLayer]? = nil + ) throws { + guard origins.keys.contains(where: \.isEmpty) == false else { + throw CodexAppServerTestError.invalidFixture( + "configuration origin keys must not be empty" + ) + } + try Self.validate(configuration) + self.configuration = configuration + self.origins = origins + self.layers = layers + } + + package var wireValue: CodexJSONValue { + var fields: [String: CodexJSONValue] = [ + "config": Self.configurationWireValue(configuration), + "origins": .object(origins.mapValues(\.wireValue)), + ] + if let layers { + fields["layers"] = .array(layers.map(\.wireValue)) + } + return .object(fields) + } + + private static func validate(_ configuration: CodexConfiguration) throws { + if let model = configuration.model { + try requireNonEmpty(model, field: "configuration model") + } + if let reviewModel = configuration.reviewModel { + try requireNonEmpty(reviewModel, field: "configuration review model") + } + if let serviceTier = configuration.serviceTier { + try requireNonEmpty(serviceTier, field: "configuration service tier") + } + } + + private static func configurationWireValue( + _ configuration: CodexConfiguration + ) -> CodexJSONValue { + .object([ + "model": configuration.model.map(CodexJSONValue.string) ?? .null, + "review_model": configuration.reviewModel.map(CodexJSONValue.string) ?? .null, + "model_context_window": .null, + "model_auto_compact_token_limit": .null, + "model_auto_compact_token_limit_scope": .null, + "model_provider": .null, + "approval_policy": .null, + "approvals_reviewer": .null, + "sandbox_mode": .null, + "sandbox_workspace_write": .null, + "forced_chatgpt_workspace_id": .null, + "forced_login_method": .null, + "web_search": .null, + "tools": .null, + "instructions": .null, + "developer_instructions": .null, + "compact_prompt": .null, + "model_reasoning_effort": configuration.reasoningEffort.map { + .string($0.rawValue) + } ?? .null, + "model_reasoning_summary": .null, + "model_verbosity": .null, + "service_tier": configuration.serviceTier.map(CodexJSONValue.string) ?? .null, + "analytics": .null, + "apps": .null, + "desktop": .null, + ]) + } +} + +public struct CodexAppServerTestConfigurationWriteResult: Equatable, Sendable { + public enum Status: Equatable, Sendable { + case ok + case okOverridden + + fileprivate var wireValue: String { + switch self { + case .ok: "ok" + case .okOverridden: "okOverridden" + } + } + } + + public struct OverriddenMetadata: Equatable, Sendable { + public var message: String + public var overridingLayer: CodexAppServerTestConfigurationLayerMetadata + public var effectiveValue: CodexJSONValue + + public init( + message: String, + overridingLayer: CodexAppServerTestConfigurationLayerMetadata, + effectiveValue: CodexJSONValue + ) throws { + try requireNonEmpty(message, field: "configuration override message") + self.message = message + self.overridingLayer = overridingLayer + self.effectiveValue = effectiveValue + } + + fileprivate var wireValue: CodexJSONValue { + .object([ + "message": .string(message), + "overridingLayer": overridingLayer.wireValue, + "effectiveValue": effectiveValue, + ]) + } + } + + public var status: Status + public var version: String + public var fileURL: URL + public var overriddenMetadata: OverriddenMetadata? + + public init( + status: Status, + version: String, + fileURL: URL, + overriddenMetadata: OverriddenMetadata? = nil + ) throws { + try requireNonEmpty(version, field: "configuration write version") + try requireAbsoluteFileURL(fileURL, field: "configuration write file") + switch (status, overriddenMetadata) { + case (.ok, nil), (.okOverridden, .some): + break + case (.ok, .some): + throw CodexAppServerTestError.invalidFixture( + "an ok configuration write must not contain override metadata" + ) + case (.okOverridden, nil): + throw CodexAppServerTestError.invalidFixture( + "an overridden configuration write requires override metadata" + ) + } + self.status = status + self.version = version + self.fileURL = fileURL + self.overriddenMetadata = overriddenMetadata + } + + package var wireValue: CodexJSONValue { + .object([ + "status": .string(status.wireValue), + "version": .string(version), + "filePath": .string(fileURL.path), + "overriddenMetadata": overriddenMetadata?.wireValue ?? .null, + ]) + } +} + +private func requireNonEmpty(_ value: String, field: String) throws { + guard value.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("\(field) must not be empty") + } +} + +private func requireAbsoluteFileURL(_ url: URL, field: String) throws { + guard url.isFileURL, url.path.hasPrefix("/") else { + throw CodexAppServerTestError.invalidFixture( + "\(field) must be an absolute file URL" + ) + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift new file mode 100644 index 00000000..8874db43 --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift @@ -0,0 +1,2734 @@ +import Foundation +import CodexAppServerKit +import Synchronization + +/// An in-memory store for app-server thread snapshots used by test stubs. +/// +/// Use this store when tests or previews need to mutate the same authoritative +/// snapshots that back `thread/list`, `thread/read`, `thread/resume`, and +/// `thread/start` in ``CodexAppServerTestTransport``. +public actor CodexAppServerTestThreadStore { + private struct PlannedStart: Sendable { + var thread: CodexAppServerTestStoredThread + } + + private struct PlannedFork: Sendable { + var sourceID: CodexThreadID + var thread: CodexAppServerTestStoredThread + } + + private var threadsByID: [String: CodexAppServerTestStoredThread] + private var threadOrder: [String] + private var plannedStarts: [PlannedStart] + private var plannedForks: [PlannedFork] = [] + private static let cursorPrefix = "test-thread-store:" + private static let threadListDefaultLimit = 25 + private static let threadListMaximumLimit = 100 + + /// Creates an in-memory store from validated opaque thread fixtures. + public init( + threads: [CodexAppServerTestStoredThread] = [], + plannedStarts: [CodexAppServerTestStoredThread] = [] + ) throws { + guard Set(threads.map(\.snapshot.id)).count == threads.count else { + throw CodexAppServerTestError.invalidFixture( + "Stored test threads must use unique identities." + ) + } + guard Set(plannedStarts.map(\.snapshot.id)).count == plannedStarts.count, + Set(threads.map(\.snapshot.id)).isDisjoint(with: plannedStarts.map(\.snapshot.id)) + else { + throw CodexAppServerTestError.invalidFixture( + "Planned test thread starts must use distinct new identities." + ) + } + self.threadsByID = Dictionary( + uniqueKeysWithValues: threads.map { ($0.snapshot.id.rawValue, $0) } + ) + self.threadOrder = threads.map(\.snapshot.id.rawValue) + self.plannedStarts = plannedStarts.map { .init(thread: $0) } + } + + /// Returns the complete validated fixture for `id`, if one exists. + public func storedThread(id: CodexThreadID) -> CodexAppServerTestStoredThread? { + threadsByID[id.rawValue] + } + + /// Inserts or replaces a stored fixture without changing an existing list position. + public func upsert(_ thread: CodexAppServerTestStoredThread) { + let id = thread.snapshot.id.rawValue + if threadsByID[id] == nil { + threadOrder.insert(id, at: 0) + } + threadsByID[id] = thread + } + + /// Enqueues the complete fixture consumed by the next `thread/start` request. + public func enqueueStart( + _ thread: CodexAppServerTestStoredThread + ) throws { + let id = thread.snapshot.id + guard threadsByID[id.rawValue] == nil, + plannedStarts.contains(where: { $0.thread.snapshot.id == id }) == false + else { + throw CodexAppServerTestError.invalidFixture( + "A planned thread start must use a new thread identity." + ) + } + plannedStarts.append(.init(thread: thread)) + } + + /// Enqueues the complete fixture consumed by the next matching `thread/fork` request. + public func enqueueFork( + _ fork: CodexAppServerTestStoredThread, + from sourceID: CodexThreadID + ) throws { + guard threadsByID[sourceID.rawValue] != nil else { + throw CodexAppServerTestError.invalidFixture( + "A planned fork source must already exist in the thread store." + ) + } + let forkID = fork.snapshot.id + guard threadsByID[forkID.rawValue] == nil, + forkID != sourceID, + plannedForks.contains(where: { $0.thread.snapshot.id == forkID }) == false + else { + throw CodexAppServerTestError.invalidFixture( + "A planned thread fork must use a new thread identity." + ) + } + guard fork.metadata.forkedFromID == sourceID else { + throw CodexAppServerTestError.invalidFixture( + "A planned fork must identify its source in thread metadata." + ) + } + plannedForks.append(.init(sourceID: sourceID, thread: fork)) + } + + /// Removes and returns the complete stored fixture for `id`, if one exists. + @discardableResult + public func remove(id: CodexThreadID) -> CodexAppServerTestStoredThread? { + threadOrder.removeAll { $0 == id.rawValue } + return threadsByID.removeValue(forKey: id.rawValue) + } + + private func replacing( + _ thread: CodexAppServerTestStoredThread, + snapshot: CodexThreadSnapshot? = nil, + isArchived: Bool? = nil + ) throws -> CodexAppServerTestStoredThread { + try .init( + snapshot: snapshot ?? thread.snapshot, + turns: thread.turns, + metadata: thread.metadata, + runtimeMetadata: thread.runtimeMetadata, + isArchived: isArchived ?? thread.isArchived + ) + } + + func startThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Start.Params.self, from: params) + guard plannedStarts.isEmpty == false else { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "thread/start requires an explicitly planned test thread." + )) + } + let planned = plannedStarts.removeFirst() + let thread = planned.thread + let snapshot = thread.snapshot + if let requestedID = request.threadID, requestedID != snapshot.id.rawValue { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/start identity does not match the request." + )) + } + if let requestedCWD = request.cwd, + requestedCWD != snapshot.workspace?.standardizedFileURL.path { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/start workspace does not match the request." + )) + } + if let requestedProvider = request.modelProvider, + requestedProvider != snapshot.modelProvider { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/start model provider does not match the request." + )) + } + if let requestedEphemeral = request.ephemeral, + requestedEphemeral != snapshot.ephemeral { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/start ephemeral flag does not match the request." + )) + } + upsert(thread) + return try JSONEncoder().encode( + thread.runtimeResponseWireValue(includingTurns: false) + ) + } + + func forkThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Fork.Params.self, from: params) + guard let sourceID = request.threadID.map(CodexThreadID.init(rawValue:)), + threadsByID[sourceID.rawValue] != nil + else { + throw Self.missingThreadError(operation: "thread/fork") + } + guard let plannedIndex = plannedForks.firstIndex(where: { $0.sourceID == sourceID }) else { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "thread/fork requires an explicitly planned test thread." + )) + } + let planned = plannedForks.remove(at: plannedIndex) + let thread = planned.thread + let snapshot = thread.snapshot + if let requestedCWD = request.cwd, + requestedCWD != snapshot.workspace?.standardizedFileURL.path { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/fork workspace does not match the request." + )) + } + if let requestedProvider = request.modelProvider, + requestedProvider != snapshot.modelProvider { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/fork model provider does not match the request." + )) + } + if let requestedEphemeral = request.ephemeral, + requestedEphemeral != snapshot.ephemeral { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "The planned thread/fork ephemeral flag does not match the request." + )) + } + upsert(thread) + return try JSONEncoder().encode( + thread.runtimeResponseWireValue(includingTurns: true) + ) + } + + func listThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.List.Params.self, from: params) + let archived = request.archived ?? false + let storedThreads = threadOrder.compactMap { id -> CodexAppServerTestStoredThread? in + guard let thread = threadsByID[id], thread.isArchived == archived else { + return nil + } + return thread + } + let filteredSnapshots = try CodexAppServerTestTransport.sortedThreadSnapshots( + CodexAppServerTestTransport.filteredThreadSnapshots( + storedThreads.map(\.snapshot), + for: request + ), + sortKey: request.sortKey, + sortDirection: request.sortDirection + ) + let filteredThreads = filteredSnapshots.compactMap { + threadsByID[$0.id.rawValue] + } + let page = try page(filteredThreads, cursor: request.cursor, limit: request.limit) + return try JSONEncoder().encode(page.wireValue) + } + + private func page( + _ threads: [CodexAppServerTestStoredThread], + cursor: String?, + limit: Int? + ) throws -> CodexAppServerTestThreadPage { + let start = min(try offset(from: cursor), threads.count) + let pageSize = min( + max(limit ?? Self.threadListDefaultLimit, 1), + Self.threadListMaximumLimit + ) + let end = min(start + pageSize, threads.count) + let previousStart = max(0, start - pageSize) + return CodexAppServerTestThreadPage( + threads: Array(threads[start.. 0 ? Self.cursor(for: previousStart) : nil + ) + } + + private func offset(from cursor: String?) throws -> Int { + guard let cursor else { + return 0 + } + guard cursor.hasPrefix(Self.cursorPrefix), + let offset = Int(cursor.dropFirst(Self.cursorPrefix.count)), + offset >= 0 else { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "Invalid test thread-store cursor." + )) + } + return offset + } + + private static func cursor(for offset: Int) -> String { + "\(cursorPrefix)\(offset)" + } + + func resumeThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Resume.Params.self, from: params) + guard let threadID = request.threadID, + let thread = threadsByID[threadID] + else { + throw JSONRPC.Error.responseError(.init( + code: -32004, + message: "No stubbed thread matches thread/resume." + )) + } + return try JSONEncoder().encode( + thread.runtimeResponseWireValue(includingTurns: true) + ) + } + + func readThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Read.Params.self, from: params) + guard let thread = threadsByID[request.threadID] else { + throw JSONRPC.Error.responseError(.init( + code: -32004, + message: "No stubbed thread matches thread/read." + )) + } + return try JSONEncoder().encode( + CodexJSONValue.object([ + "thread": thread.wireValue(includingTurns: request.includeTurns == true), + ]) + ) + } + + func listThreadTurnsResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode( + AppServerAPI.Thread.Turns.List.Params.self, + from: params + ) + guard let thread = threadsByID[request.threadID] else { + throw JSONRPC.Error.responseError(.init( + code: -32004, + message: "No stubbed thread matches thread/turns/list." + )) + } + var turns = thread.turns + if request.sortDirection == .descending { + turns.reverse() + } + let page = try page(turns, cursor: request.cursor, limit: request.limit) + return try JSONEncoder().encode(page.wireValue) + } + + private func page( + _ turns: [CodexAppServerTestTurn], + cursor: String?, + limit: Int? + ) throws -> CodexAppServerTestTurnPage { + let start = min(try offset(from: cursor), turns.count) + guard let limit else { + return CodexAppServerTestTurnPage( + turns: Array(turns[start.. 0 ? Self.cursor(for: 0) : nil + ) + } + guard limit > 0 else { + return CodexAppServerTestTurnPage( + turns: [], + nextCursor: nil, + backwardsCursor: nil + ) + } + + let end = min(start + limit, turns.count) + let previousStart = max(0, start - limit) + return CodexAppServerTestTurnPage( + turns: Array(turns[start.. 0 ? Self.cursor(for: previousStart) : nil + ) + } + + func archiveThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Archive.Params.self, from: params) + guard let thread = threadsByID[request.threadID] else { + throw Self.missingThreadError(operation: "thread/archive") + } + threadsByID[request.threadID] = try replacing(thread, isArchived: true) + return try JSONEncoder().encode(EmptyResponse()) + } + + func unarchiveThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Unarchive.Params.self, from: params) + guard let thread = threadsByID[request.threadID] else { + throw Self.missingThreadError(operation: "thread/unarchive") + } + let unarchived = try replacing(thread, isArchived: false) + threadsByID[request.threadID] = unarchived + return try JSONEncoder().encode(CodexJSONValue.object([ + "thread": unarchived.wireValue(includingTurns: false), + ])) + } + + func deleteThreadResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Delete.Params.self, from: params) + guard remove(id: .init(rawValue: request.threadID)) != nil else { + throw Self.missingThreadError(operation: "thread/delete") + } + return try JSONEncoder().encode(EmptyResponse()) + } + + func setThreadNameResponse(for params: Data) throws -> Data { + let request = try JSONDecoder().decode(AppServerAPI.Thread.Name.Set.Params.self, from: params) + guard let thread = threadsByID[request.threadID] else { + throw Self.missingThreadError(operation: "thread/name/set") + } + var snapshot = thread.snapshot + snapshot.name = request.name + threadsByID[request.threadID] = try replacing(thread, snapshot: snapshot) + return try JSONEncoder().encode(EmptyResponse()) + } + + private static func missingThreadError(operation: String) -> JSONRPC.Error { + .responseError(.init( + code: -32004, + message: "No stored test thread matches \(operation)." + )) + } +} + +private extension CodexAppServerTestStoredThread { + func runtimeResponseWireValue( + includingTurns: Bool, + initialTurnsPage: CodexAppServerTestTurnPage? = nil + ) -> CodexJSONValue { + guard case .object(var fields) = runtimeMetadata.wireValue else { + preconditionFailure("Thread runtime metadata must own an object wire value.") + } + fields["thread"] = wireValue(includingTurns: includingTurns) + if let initialTurnsPage { + fields["initialTurnsPage"] = initialTurnsPage.wireValue + } + return .object(fields) + } +} + +/// A manually advanced monotonic clock for deterministic deadline tests. +public final class CodexAppServerTestDeadlineClock: Sendable { + private enum Registration { + case ready + case closed + case waiting + } + + private final class Waiter: Sendable { + private enum State: Sendable { + case pending(CheckedContinuation, Never>?) + case resolved(Result) + } + + private let state = Mutex(.pending(nil)) + + func wait() async throws { + let result = await withCheckedContinuation { continuation in + let resolved = state.withLock { + state -> Result? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("A deadline-clock waiter can suspend exactly once.") + case .resolved(let result): + return result + } + } + if let resolved { + continuation.resume(returning: resolved) + } + } + try result.get() + } + + func resolve(_ result: Result) { + let continuation = state.withLock { + state -> CheckedContinuation, Never>? in + switch state { + case .pending(let continuation): + state = .resolved(result) + return continuation + case .resolved: + return nil + } + } + continuation?.resume(returning: result) + } + } + + private struct Sleeper: Sendable { + var deadline: Duration + var waiter: Waiter + } + + private struct CountObserver: Sendable { + var count: Int + var waiter: Waiter + } + + private struct State: Sendable { + var now: Duration = .zero + var isClosed = false + var sleepers: [UUID: Sleeper] = [:] + var countObservers: [UUID: CountObserver] = [:] + } + + private let state = Mutex(State()) + + public init() {} + + /// Advances the clock and resumes sleepers whose deadline is now due. + public func advance(by duration: Duration) { + precondition(duration >= .zero, "A deadline clock cannot move backwards.") + let waiters = state.withLock { state -> [Waiter] in + guard state.isClosed == false else { + return [] + } + state.now += duration + let dueIDs = state.sleepers.compactMap { id, sleeper in + sleeper.deadline <= state.now ? id : nil + } + return dueIDs.compactMap { state.sleepers.removeValue(forKey: $0)?.waiter } + } + for waiter in waiters { + waiter.resolve(.success(())) + } + } + + /// Suspends until at least `count` deadline sleepers have registered. + public func waitForSleeperCount(_ count: Int) async throws { + precondition(count >= 0, "A sleeper count cannot be negative.") + try Task.checkCancellation() + let id = UUID() + let waiter = Waiter() + let registration = state.withLock { state -> Registration in + if state.sleepers.count >= count { + return .ready + } + if state.isClosed { + return .closed + } + state.countObservers[id] = .init(count: count, waiter: waiter) + return .waiting + } + switch registration { + case .ready: + return + case .closed: + throw CancellationError() + case .waiting: + break + } + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await waiter.wait() + } onCancel: { + self.cancelCountObserver(id: id, waiter: waiter) + } + } + + /// Closes the clock and cancels every pending sleeper and observer. + public func close() { + let waiters = state.withLock { state -> [Waiter] in + guard state.isClosed == false else { + return [] + } + state.isClosed = true + let waiters = state.sleepers.values.map(\.waiter) + + state.countObservers.values.map(\.waiter) + state.sleepers.removeAll(keepingCapacity: false) + state.countObservers.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resolve(.failure(CancellationError())) + } + } + + package var codexDeadlineClock: CodexDeadlineClock { + .init { [self] duration in + try await sleep(for: duration) + } + } + + private func sleep(for duration: Duration) async throws { + try Task.checkCancellation() + guard duration > .zero else { + return + } + let id = UUID() + let waiter = Waiter() + let registration = state.withLock { + state -> (Registration, [Waiter]) in + guard state.isClosed == false else { + return (.closed, []) + } + state.sleepers[id] = .init(deadline: state.now + duration, waiter: waiter) + let observers = readyCountObservers(in: &state) + return (.waiting, observers) + } + for observer in registration.1 { + observer.resolve(.success(())) + } + switch registration.0 { + case .ready: + preconditionFailure("A positive deadline sleep cannot complete at registration.") + case .closed: + throw CancellationError() + case .waiting: + break + } + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await waiter.wait() + } onCancel: { + self.cancelSleeper(id: id, waiter: waiter) + } + } + + private func readyCountObservers(in state: inout State) -> [Waiter] { + let readyIDs = state.countObservers.compactMap { id, observer in + observer.count <= state.sleepers.count ? id : nil + } + return readyIDs.compactMap { state.countObservers.removeValue(forKey: $0)?.waiter } + } + + private func cancelSleeper(id: UUID, waiter: Waiter) { + let removed = state.withLock { state -> Bool in + guard state.sleepers[id]?.waiter === waiter else { + return false + } + state.sleepers.removeValue(forKey: id) + return true + } + if removed { + waiter.resolve(.failure(CancellationError())) + } + } + + private func cancelCountObserver(id: UUID, waiter: Waiter) { + let removed = state.withLock { state -> Bool in + guard state.countObservers[id]?.waiter === waiter else { + return false + } + state.countObservers.removeValue(forKey: id) + return true + } + if removed { + waiter.resolve(.failure(CancellationError())) + } + } +} + +/// A Codex app-server test runtime backed by an in-memory transport. +/// +/// This type does not launch `codex` or any external process. Tests enqueue +/// responses and emit notifications through ``transport`` while exercising the +/// same public ``CodexAppServer`` API that production code uses. +public struct CodexAppServerTestRuntime: Sendable { + /// The app-server domain container under test. + public let server: CodexAppServer + + /// The in-memory transport used by ``server``. + public let transport: CodexAppServerTestTransport + + /// Typed current-v2 notification controls for the in-memory server. + public let notificationEmitter: CodexAppServerTestNotificationEmitter + + /// The authoritative thread store, when this runtime was started in store mode. + public let threadStore: CodexAppServerTestThreadStore? + + /// The manually controlled deadline clock, when one was supplied at startup. + public let deadlineClock: CodexAppServerTestDeadlineClock? + + /// Creates a runtime from an already initialized app-server container and transport. + package init( + server: CodexAppServer, + transport: CodexAppServerTestTransport, + threadStore: CodexAppServerTestThreadStore? = nil, + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) { + self.server = server + self.transport = transport + self.threadStore = threadStore + self.deadlineClock = deadlineClock + self.notificationEmitter = CodexAppServerTestNotificationEmitter(transport: transport) + } + + /// Closes the app-server connection and drains the in-memory transport. + public func close() async { + await server.close() + await transport.close() + deadlineClock?.close() + } + + /// Creates a test runtime without launching a real app-server process. + /// + /// The runtime automatically enqueues the `initialize` response required by + /// ``CodexAppServer`` startup. + /// + /// - Parameters: + /// - transport: The in-memory transport to use. + /// - configuration: The same connection configuration used by production. + /// - deadlineClock: An optional manually advanced monotonic deadline clock. + /// - Returns: A started test runtime. + public static func start( + transport: CodexAppServerTestTransport = CodexAppServerTestTransport(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> CodexAppServerTestRuntime { + try await transport.enqueueInitialize( + codexHome: configuration.localProcess.codexHomeURL.path, + userAgent: nil + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + clock: configuration.clock, + deadlines: configuration.deadlines, + deadlineClock: deadlineClock?.codexDeadlineClock ?? configuration.deadlineClock, + handler: configuration.serverRequestHandler + ) + do { + _ = try await harness.client.initialize( + clientName: configuration.clientName, + clientVersion: configuration.clientVersion + ) + } catch { + await harness.close() + await transport.close() + deadlineClock?.close() + throw error + } + return .init( + server: harness.server, + transport: transport, + deadlineClock: deadlineClock + ) + } + + /// Creates a test runtime whose app-server thread APIs are backed by a mutable store. + /// + /// The returned runtime still exercises the public ``CodexAppServer`` API, + /// while callers can mutate `threadStore` after startup: + /// + /// ```swift + /// let store = CodexAppServerTestThreadStore(threads: threads) + /// let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + /// await store.upsert(updatedThread) + /// ``` + public static func start( + threadStore: CodexAppServerTestThreadStore, + transport: CodexAppServerTestTransport = CodexAppServerTestTransport(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> CodexAppServerTestRuntime { + try await transport.stubThreads(threadStore) + let runtime = try await start( + transport: transport, + configuration: configuration, + deadlineClock: deadlineClock + ) + return .init( + server: runtime.server, + transport: runtime.transport, + threadStore: threadStore, + deadlineClock: runtime.deadlineClock + ) + } + + /// Creates a test runtime whose thread APIs are backed by validated opaque fixtures. + /// + /// The returned runtime still exercises the public ``CodexAppServer`` API. + /// Higher-level code can build its normal data container from ``server``: + /// + /// ```swift + /// let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + /// let container = CodexModelContainer(appServer: runtime.server) + /// ``` + public static func start( + threads: [CodexAppServerTestStoredThread], + transport: CodexAppServerTestTransport = CodexAppServerTestTransport(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> CodexAppServerTestRuntime { + let threadStore = try CodexAppServerTestThreadStore(threads: threads) + return try await start( + threadStore: threadStore, + transport: transport, + configuration: configuration, + deadlineClock: deadlineClock + ) + } +} + +public enum CodexAppServerTestOperation: Equatable, Sendable { + case initialize + case threadStart + case threadResume + case threadFork + case threadList + case threadRead + case threadTurnsList + case threadArchive + case threadUnarchive + case threadDelete + case threadRename + case threadCompact + case threadRollback + case turnStart + case turnInterrupt + case reviewStart + case modelList + case accountRead + case accountRateLimitsRead + case accountLoginStart + case accountLoginCancel + case accountLogout + case configurationRead + case configurationUpdate + + package var method: String { + switch self { + case .initialize: "initialize" + case .threadStart: "thread/start" + case .threadResume: "thread/resume" + case .threadFork: "thread/fork" + case .threadList: "thread/list" + case .threadRead: "thread/read" + case .threadTurnsList: "thread/turns/list" + case .threadArchive: "thread/archive" + case .threadUnarchive: "thread/unarchive" + case .threadDelete: "thread/delete" + case .threadRename: "thread/name/set" + case .threadCompact: "thread/compact/start" + case .threadRollback: "thread/rollback" + case .turnStart: "turn/start" + case .turnInterrupt: "turn/interrupt" + case .reviewStart: "review/start" + case .modelList: "model/list" + case .accountRead: "account/read" + case .accountRateLimitsRead: "account/rateLimits/read" + case .accountLoginStart: "account/login/start" + case .accountLoginCancel: "account/login/cancel" + case .accountLogout: "account/logout" + case .configurationRead: "config/read" + case .configurationUpdate: "config/batchWrite" + } + } + + package init?(method: String) { + guard let operation = Self.allCasesByMethod[method] else { + return nil + } + self = operation + } + + private static let allCasesByMethod: [String: Self] = [ + "initialize": .initialize, + "thread/start": .threadStart, + "thread/resume": .threadResume, + "thread/fork": .threadFork, + "thread/list": .threadList, + "thread/read": .threadRead, + "thread/turns/list": .threadTurnsList, + "thread/archive": .threadArchive, + "thread/unarchive": .threadUnarchive, + "thread/delete": .threadDelete, + "thread/name/set": .threadRename, + "thread/compact/start": .threadCompact, + "thread/rollback": .threadRollback, + "turn/start": .turnStart, + "turn/interrupt": .turnInterrupt, + "review/start": .reviewStart, + "model/list": .modelList, + "account/read": .accountRead, + "account/rateLimits/read": .accountRateLimitsRead, + "account/login/start": .accountLoginStart, + "account/login/cancel": .accountLoginCancel, + "account/logout": .accountLogout, + "config/read": .configurationRead, + "config/batchWrite": .configurationUpdate, + ] +} + +public enum CodexAppServerTestRequest: Equatable, Sendable { + case initialize + case threadStart( + workspace: URL, + instructions: CodexInstructions?, + options: CodexThread.Options + ) + case threadResume(id: CodexThreadID, options: CodexThread.ResumeOptions) + case threadFork(id: CodexThreadID, options: CodexThread.Options) + case threadList(CodexThreadQuery) + case threadRead(id: CodexThreadID, includeTurns: Bool) + case threadTurnsList(threadID: CodexThreadID, query: CodexTurnQuery) + case threadArchive(CodexThreadID) + case threadUnarchive(CodexThreadID) + case threadDelete(CodexThreadID) + case threadRename(id: CodexThreadID, name: String) + case threadCompact(CodexThreadID) + case threadRollback(id: CodexThreadID, numberOfTurns: Int) + case turnStart( + threadID: CodexThreadID, + prompt: CodexPrompt, + options: CodexGenerationOptions + ) + case turnInterrupt(threadID: CodexThreadID, turnID: CodexTurnID) + case reviewStart( + threadID: CodexThreadID, + target: CodexReviewTarget, + delivery: CodexReviewDelivery + ) + case modelList(includeHidden: Bool) + case accountRead(refreshToken: Bool) + case accountRateLimitsRead + case accountLoginStart + case accountLoginCancel(CodexLoginHandle.ID) + case accountLogout + case configurationRead + case configurationUpdate(CodexConfigurationPatch) + + public var operation: CodexAppServerTestOperation { + switch self { + case .initialize: .initialize + case .threadStart: .threadStart + case .threadResume: .threadResume + case .threadFork: .threadFork + case .threadList: .threadList + case .threadRead: .threadRead + case .threadTurnsList: .threadTurnsList + case .threadArchive: .threadArchive + case .threadUnarchive: .threadUnarchive + case .threadDelete: .threadDelete + case .threadRename: .threadRename + case .threadCompact: .threadCompact + case .threadRollback: .threadRollback + case .turnStart: .turnStart + case .turnInterrupt: .turnInterrupt + case .reviewStart: .reviewStart + case .modelList: .modelList + case .accountRead: .accountRead + case .accountRateLimitsRead: .accountRateLimitsRead + case .accountLoginStart: .accountLoginStart + case .accountLoginCancel: .accountLoginCancel + case .accountLogout: .accountLogout + case .configurationRead: .configurationRead + case .configurationUpdate: .configurationUpdate + } + } +} + +/// A semantic request recorded at the Testing transport boundary. +public struct CodexAppServerRecordedRequest: Equatable, Sendable { + public let sequence: UInt64 + public let requestID: Int + public let request: CodexAppServerTestRequest + + package let method: String + package let params: Data + + package init( + sequence: UInt64, + requestID: Int, + request: CodexAppServerTestRequest, + method: String, + params: Data + ) { + self.sequence = sequence + self.requestID = requestID + self.request = request + self.method = method + self.params = params + } + + package var id: Int { requestID } + + package func decodeParams( + _ type: Value.Type = Value.self + ) throws -> Value { + try JSONDecoder().decode(type, from: params) + } +} + +package struct CodexAppServerRecordedRawRequest: Equatable, Sendable { + package let sequence: UInt64 + package let requestID: Int + package let method: String + package let params: Data + + package var id: Int { requestID } + + package func decodeParams( + _ type: Value.Type = Value.self + ) throws -> Value { + try JSONDecoder().decode(type, from: params) + } +} + +package struct CodexAppServerRecordedNotification: Equatable, Sendable { + package var method: String + package var params: Data + + package init(method: String, params: Data) { + self.method = method + self.params = params + } + + package func decodeParams( + _ type: Value.Type = Value.self + ) throws -> Value { + try JSONDecoder().decode(type, from: params) + } +} + +/// A typed `turn/interrupt` request observed by a Testing runtime handler. +public struct CodexAppServerTestTurnInterruptRequest: Equatable, Sendable { + public var threadID: CodexThreadID + public var turnID: CodexTurnID + + public init(threadID: CodexThreadID, turnID: CodexTurnID) { + self.threadID = threadID + self.turnID = turnID + } +} + +public enum CodexAppServerTestRequestFailure: Equatable, Sendable { + case response(code: Int, message: String) + + fileprivate var jsonRPCError: JSONRPC.Error { + switch self { + case .response(let code, let message): + .responseError(.init(code: code, message: message)) + } + } +} + +public enum CodexAppServerTestLoginCancellationStatus: Equatable, Sendable { + case canceled + case notFound + + fileprivate var wireValue: String { + switch self { + case .canceled: "canceled" + case .notFound: "notFound" + } + } +} + +/// A deterministic gate for app-server concurrency tests. +/// +/// Use this to hold a request at a known point and release it explicitly, +/// instead of depending on sleeps or repeated `Task.yield()` calls. +public final class CodexAppServerTestGate: Sendable { + private enum WaitRegistration { + case ready + case closed + case waiting + } + + private final class Waiter: Sendable { + private enum State: Sendable { + case pending(CheckedContinuation, Never>?) + case resolved(Result) + } + + private let state = Mutex(.pending(nil)) + + func wait() async throws { + let result = await withCheckedContinuation { continuation in + let resolved = state.withLock { + state -> Result? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("A test gate waiter can suspend exactly once.") + case .resolved(let result): + return result + } + } + if let resolved { + continuation.resume(returning: resolved) + } + } + try result.get() + } + + @discardableResult + func resolve(_ result: Result) -> Bool { + let continuation = state.withLock { + state -> CheckedContinuation, Never>? in + switch state { + case .pending(let continuation): + state = .resolved(result) + return continuation + case .resolved: + return nil + } + } + continuation?.resume(returning: result) + return continuation != nil + } + } + + private struct State: Sendable { + var isOpen = false + var isClosed = false + var waiters: [UUID: Waiter] = [:] + var blockedObservers: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + + /// Creates a closed gate. + public init() {} + + /// Suspends until the gate opens, or until the waiting task is cancelled. + public func wait() async throws { + try Task.checkCancellation() + let waiterID = UUID() + let waiter = Waiter() + let registration = state.withLock { state -> WaitRegistration in + if state.isOpen { + return .ready + } + if state.isClosed { + return .closed + } + state.waiters[waiterID] = waiter + resumeBlockedObservers(in: &state) + return .waiting + } + switch registration { + case .ready: + return + case .closed: + throw CancellationError() + case .waiting: + break + } + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await waiter.wait() + } onCancel: { + self.cancelWaiter(id: waiterID, waiter: waiter) + } + } + + /// Suspends until the gate opens, ignoring task cancellation while waiting. + public func waitIgnoringCancellation() async { + let waiterID = UUID() + let waiter = Waiter() + let shouldWait = state.withLock { state in + guard state.isOpen == false, state.isClosed == false else { + return false + } + state.waiters[waiterID] = waiter + resumeBlockedObservers(in: &state) + return true + } + guard shouldWait else { + return + } + try? await waiter.wait() + } + + /// Suspends until at least one task is waiting at this gate. + public func waitUntilBlocked() async { + let shouldWait = state.withLock { state in + state.isOpen == false && state.isClosed == false && state.waiters.isEmpty + } + guard shouldWait else { + return + } + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isOpen || state.isClosed || state.waiters.isEmpty == false { + return true + } + state.blockedObservers.append(continuation) + return false + } + if shouldResume { + continuation.resume() + } + } + } + + /// Opens the gate and resumes all suspended waiters. + public func open() async { + let waiters = state.withLock { state -> [Waiter] in + guard state.isOpen == false, state.isClosed == false else { + return [] + } + state.isOpen = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + resumeBlockedObservers(in: &state) + return waiters + } + for waiter in waiters { + waiter.resolve(.success(())) + } + } + + /// Closes the gate and drains every waiter without opening it for future waits. + public func close() async { + let waiters = state.withLock { state -> [Waiter] in + guard state.isClosed == false else { + return [] + } + state.isClosed = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + resumeBlockedObservers(in: &state) + return waiters + } + for waiter in waiters { + waiter.resolve(.failure(CancellationError())) + } + } + + private func resumeBlockedObservers(in state: inout State) { + guard state.waiters.isEmpty == false || state.isOpen || state.isClosed else { + return + } + let observers = state.blockedObservers + state.blockedObservers.removeAll(keepingCapacity: false) + for observer in observers { + observer.resume() + } + } + + private func cancelWaiter(id: UUID, waiter: Waiter) { + let removed = state.withLock { state -> Bool in + guard state.waiters[id] === waiter else { + return false + } + state.waiters.removeValue(forKey: id) + return true + } + if removed { + waiter.resolve(.failure(CancellationError())) + } + } + +} + +/// An in-memory app-server transport for tests. +public actor CodexAppServerTestTransport { + private enum ThreadRuntimeMode: Sendable { + case queuedResponses + case authoritativeThreadStore(CodexAppServerTestThreadStore) + } + + package nonisolated let connectionEventHub = ConnectionEventHub() + private struct RequestGate: Sendable { + var gate: CodexAppServerTestGate + var ignoresCancellation: Bool + + func wait() async throws { + if ignoresCancellation { + await gate.waitIgnoringCancellation() + } else { + try await gate.wait() + } + } + } + + private enum QueuedResponse: Sendable { + case success(Data) + case failure(JSONRPC.Error) + } + + private typealias ResponseHandler = @Sendable (Data) async throws -> Data + + private var responses: [String: [QueuedResponse]] = [:] + private var responseHandlers: [String: ResponseHandler] = [:] + private var requests: [JSONRPC.Request] = [] + private var notifications: [JSONRPC.Notification] = [] + private let mailbox = JSONRPCInboundFrameMailbox() + private var pendingResponses: [Int: JSONRPCResponseWaiter] = [:] + private var serverRequestResponses: [CodexAppServerTestServerResponse] = [] + private var serverRequestResponseWaiters: + [CodexServerRequestID: [CheckedContinuation]] = [:] + private var activeByMethod: [String: Int] = [:] + private var maxActiveByMethod: [String: Int] = [:] + private var gatesByMethod: [String: RequestGate] = [:] + private var oneShotGatesByMethod: [String: [RequestGate]] = [:] + private var activeRequestGatesByRequestID: [Int: RequestGate] = [:] + private var requestCountWaiters: [(Int, CheckedContinuation)] = [] + private var requestMethodWaiters: [(String, Int, CheckedContinuation)] = [] + private var notificationStreamCountWaiters: [(Int, CheckedContinuation)] = [] + private var inboundEventDeliveryGate: CodexAppServerTestGate? + private var isHoldingInboundEventDelivery = false + private var inboundEventDeliveryHeldWaiters: [CheckedContinuation] = [] + private var hasInboundConsumer = false + private var inboundTerminalObserved = false + private var closed = false + private var closeStarted = false + private var threadRuntimeMode: ThreadRuntimeMode? + + /// Creates an in-memory app-server transport. + public init() {} + + package init(responses: [String: [Data]]) { + self.responses = responses.mapValues { $0.map(QueuedResponse.success) } + } + + /// Package-only raw response seam for codec and malformed-protocol tests. + package func enqueue( + _ response: Response, + for method: String + ) throws { + try registerQueuedThreadResponse(for: method) + responses[method, default: []].append(.success(try JSONEncoder().encode(response))) + } + + /// Package-only reusable raw handler seam for protocol tests. + package func handle( + method: String, + handler: @escaping @Sendable (Data) async throws -> Data + ) throws { + try registerQueuedThreadResponse(for: method) + responseHandlers[method] = handler + } + + package func clearHandler(method: String) { + responseHandlers[method] = nil + } + + /// Handles repeated turn-interrupt requests through their canonical typed contract. + public func handleTurnInterrupt( + _ handler: @escaping @Sendable (CodexAppServerTestTurnInterruptRequest) async throws -> Void + ) throws { + try handle(method: AppServerAPI.Turn.Interrupt.Request.method) { params in + let request = try JSONDecoder().decode( + AppServerAPI.Turn.Interrupt.Params.self, + from: params + ) + try await handler(.init( + threadID: .init(rawValue: request.threadID), + turnID: .init(rawValue: request.turnID) + )) + return try JSONEncoder().encode(EmptyResponse()) + } + } + + /// Package-only reusable raw response seam for protocol tests. + package func stub( + _ response: Response, + for method: String + ) throws { + let encoded = try JSONEncoder().encode(response) + try handle(method: method) { _ in encoded } + } + + /// Package-only malformed/future-schema response seam. + package func enqueueJSON(_ json: String, for method: String) throws { + try registerQueuedThreadResponse(for: method) + responses[method, default: []].append(.success(Data(json.utf8))) + } + + /// Package-only reusable malformed/future-schema response seam. + package func stubJSON(_ json: String, for method: String) throws { + let encoded = Data(json.utf8) + try handle(method: method) { _ in encoded } + } + + /// Package-only failure seam for request error tests. + package func enqueueFailure(code: Int, message: String, for method: String) { + responses[method, default: []].append(.failure(.responseError(.init( + code: code, + message: message + )))) + } + + package func enqueueFailure(_ error: JSONRPC.Error, for method: String) { + responses[method, default: []].append(.failure(error)) + } + + /// Enqueues a response failure for one supported public Testing operation. + public func enqueueFailure( + _ failure: CodexAppServerTestRequestFailure, + for operation: CodexAppServerTestOperation + ) throws { + try registerQueuedThreadResponse(for: operation.method) + responses[operation.method, default: []].append(.failure(failure.jsonRPCError)) + } + + /// Enqueues a pinned empty-object success response. + public func enqueueSuccess(for operation: CodexAppServerTestOperation) throws { + switch operation { + case .threadArchive, .threadDelete, .threadRename, .threadCompact, + .turnInterrupt, .accountLogout: + try enqueue(EmptyResponse(), for: operation.method) + case .initialize, .threadStart, .threadResume, .threadFork, .threadList, + .threadRead, .threadTurnsList, .threadUnarchive, .threadRollback, + .turnStart, .reviewStart, .modelList, .accountRead, + .accountRateLimitsRead, .accountLoginStart, .accountLoginCancel, + .configurationRead, .configurationUpdate: + throw CodexAppServerTestError.invalidFixture( + "\(operation.method) requires its typed Testing response." + ) + } + } + + /// Package-only empty response seam for pinned codec tests. + package func enqueueEmpty(for method: String) throws { + try enqueue(EmptyResponse(), for: method) + } + + /// Enqueues a thread-start response from its complete canonical fixture. + public func enqueueThreadStart( + _ thread: CodexAppServerTestStoredThread + ) throws { + try enqueue(thread.runtimeResponseWireValue(includingTurns: false), for: "thread/start") + } + + /// Enqueues a thread-resume response from its complete canonical fixture. + public func enqueueThreadResume( + _ thread: CodexAppServerTestStoredThread, + initialTurnsPage: CodexAppServerTestTurnPage? = nil + ) throws { + try enqueue( + thread.runtimeResponseWireValue( + includingTurns: true, + initialTurnsPage: initialTurnsPage + ), + for: "thread/resume" + ) + } + + /// Enqueues a thread-fork response from its complete canonical fixture. + public func enqueueThreadFork( + _ thread: CodexAppServerTestStoredThread + ) throws { + try enqueue(thread.runtimeResponseWireValue(includingTurns: true), for: "thread/fork") + } + + /// Enqueues a thread-list response from validated stored fixtures. + public func enqueueThreadList(_ page: CodexAppServerTestThreadPage) throws { + try enqueue(page.wireValue, for: "thread/list") + } + + /// Enqueues a thread-read response from its complete canonical fixture. + public func enqueueThreadRead( + _ thread: CodexAppServerTestStoredThread + ) throws { + try enqueue( + CodexJSONValue.object(["thread": thread.wireValue(includingTurns: true)]), + for: "thread/read" + ) + } + + /// Enqueues a thread-turns-list response from validated turn fixtures. + public func enqueueThreadTurns(_ page: CodexAppServerTestTurnPage) throws { + try enqueue(page.wireValue, for: "thread/turns/list") + } + + /// Stubs thread operations from validated opaque fixtures. + /// + /// This is useful for UI previews and tests that should exercise the same + /// app-server/DataKit path repeatedly without launching a real app-server. + public func stubThreads( + _ threads: [CodexAppServerTestStoredThread] + ) throws { + let store = try CodexAppServerTestThreadStore(threads: threads) + try stubThreads(store) + } + + /// Stubs thread requests from an in-memory store that callers can mutate later. + /// + /// The store remains the authoritative source for `thread/list`, + /// `thread/read`, `thread/resume`, and `thread/start` responses. + public func stubThreads(_ store: CodexAppServerTestThreadStore) throws { + try configureAuthoritativeThreadStore(store) + try handleAuthoritativeThreadMethod(method: "thread/start") { params in + try await store.startThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/fork") { params in + try await store.forkThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/list") { params in + try await store.listThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/resume") { params in + try await store.resumeThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/read") { params in + try await store.readThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/turns/list") { params in + try await store.listThreadTurnsResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/archive") { params in + try await store.archiveThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/unarchive") { params in + try await store.unarchiveThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/delete") { params in + try await store.deleteThreadResponse(for: params) + } + try handleAuthoritativeThreadMethod(method: "thread/name/set") { params in + try await store.setThreadNameResponse(for: params) + } + } + + private func handleAuthoritativeThreadMethod( + method: String, + handler: @escaping @Sendable (Data) async throws -> Data + ) throws { + guard case .authoritativeThreadStore = threadRuntimeMode else { + throw CodexAppServerTestError.invalidFixture( + "Authoritative thread handlers require thread-store mode." + ) + } + responseHandlers[method] = handler + } + + private func configureAuthoritativeThreadStore( + _ store: CodexAppServerTestThreadStore + ) throws { + switch threadRuntimeMode { + case nil: + threadRuntimeMode = .authoritativeThreadStore(store) + case .authoritativeThreadStore(let existing) where existing === store: + return + case .authoritativeThreadStore: + throw CodexAppServerTestError.invalidFixture( + "A test transport can bind exactly one authoritative thread store." + ) + case .queuedResponses: + throw CodexAppServerTestError.invalidFixture( + "Queued thread responses and an authoritative thread store are mutually exclusive." + ) + } + } + + private func registerQueuedThreadResponse(for method: String) throws { + guard Self.threadOwnedMethods.contains(method) else { + return + } + switch threadRuntimeMode { + case nil: + threadRuntimeMode = .queuedResponses + case .queuedResponses: + return + case .authoritativeThreadStore: + throw CodexAppServerTestError.invalidFixture( + "Cannot enqueue \(method) while an authoritative thread store owns thread state." + ) + } + } + + private static let threadOwnedMethods: Set = [ + "thread/start", + "thread/fork", + "thread/list", + "thread/read", + "thread/resume", + "thread/turns/list", + "thread/archive", + "thread/unarchive", + "thread/delete", + "thread/name/set", + ] + + /// Enqueues a thread-unarchive response from its canonical fixture. + public func enqueueThreadUnarchive( + _ thread: CodexAppServerTestStoredThread + ) throws { + try enqueue( + CodexJSONValue.object(["thread": thread.wireValue(includingTurns: false)]), + for: "thread/unarchive" + ) + } + + /// Enqueues a thread-rollback response from its canonical fixture. + public func enqueueThreadRollback( + _ thread: CodexAppServerTestStoredThread + ) throws { + try enqueue( + CodexJSONValue.object(["thread": thread.wireValue(includingTurns: true)]), + for: "thread/rollback" + ) + } + + /// Enqueues a turn-start response from its canonical fixture. + public func enqueueTurnStart(_ turn: CodexAppServerTestTurn) throws { + try enqueue(CodexJSONValue.object(["turn": turn.wireValue]), for: "turn/start") + } + + /// Enqueues a review-start response from its canonical fixture. + public func enqueueReviewStart( + _ turn: CodexAppServerTestTurn, + reviewThreadID: CodexThreadID + ) throws { + try enqueue( + CodexJSONValue.object([ + "turn": turn.wireValue, + "reviewThreadId": .string(reviewThreadID.rawValue), + ]), + for: "review/start" + ) + } + + /// Enqueues a model-list response from its opaque canonical fixture. + public func enqueueModels(_ page: CodexAppServerTestModelPage) throws { + try enqueue(page.wireValue, for: CodexAppServerTestOperation.modelList.method) + } + + package func enqueueModels(_ models: [CodexModel], nextCursor: String? = nil) throws { + try enqueue( + AppServerAPI.Model.List.Response(data: models, nextCursor: nextCursor), + for: "model/list" + ) + } + + /// Enqueues an account-read response from its opaque canonical fixture. + public func enqueueAccount( + _ account: CodexAppServerTestAccount?, + requiresOpenAIAuth: Bool + ) throws { + try enqueue( + CodexJSONValue.object([ + "account": account?.wireValue ?? .null, + "requiresOpenaiAuth": .bool(requiresOpenAIAuth), + ]), + for: CodexAppServerTestOperation.accountRead.method + ) + } + + /// Enqueues a config-read response from its opaque canonical fixture. + public func enqueueConfiguration( + _ result: CodexAppServerTestConfigurationReadResult + ) throws { + try enqueue( + result.wireValue, + for: CodexAppServerTestOperation.configurationRead.method + ) + } + + package func enqueueConfiguration(_ configuration: CodexConfiguration) throws { + try enqueue( + AppServerAPI.Config.Read.Response(config: .init( + model: configuration.model, + reviewModel: configuration.reviewModel, + modelReasoningEffort: configuration.reasoningEffort?.rawValue, + serviceTier: configuration.serviceTier + )), + for: "config/read" + ) + } + + /// Enqueues a config-write response from its opaque canonical fixture. + public func enqueueConfigurationWrite( + _ result: CodexAppServerTestConfigurationWriteResult + ) throws { + try enqueue( + result.wireValue, + for: CodexAppServerTestOperation.configurationUpdate.method + ) + } + + /// Enqueues an account rate-limit response from its opaque canonical fixture. + public func enqueueRateLimits(_ response: CodexAppServerTestRateLimitsResponse) throws { + try enqueue( + response.wireValue, + for: CodexAppServerTestOperation.accountRateLimitsRead.method + ) + } + + package func enqueueRateLimits(_ rateLimits: CodexRateLimits) throws { + let windows = rateLimits.windows + let primary = windows.first.map(Self.window) + let secondary = windows.dropFirst().first.map(Self.window) + try enqueue( + AppServerAPI.Account.RateLimits.Response(rateLimits: .init( + limitID: "codex", + primary: primary, + secondary: secondary, + planType: rateLimits.planType + )), + for: "account/rateLimits/read" + ) + } + + fileprivate static func filteredThreadSnapshots( + _ snapshots: [CodexThreadSnapshot], + for request: AppServerAPI.Thread.List.Params + ) -> [CodexThreadSnapshot] { + enum SourceFilter { + case interactiveDefaults + case explicit([CodexThreadSourceKind]) + + init(_ rawKinds: [String]?) { + guard let rawKinds, rawKinds.isEmpty == false else { + self = .interactiveDefaults + return + } + self = .explicit(rawKinds.map(CodexThreadSourceKind.init(rawValue:))) + } + + func includes(_ source: CodexThreadSessionSource) -> Bool { + switch self { + case .interactiveDefaults: + switch source { + case .cli, .vscode, .custom("atlas"), .custom("chatgpt"): + true + case .exec, .appServer, .custom, .subAgent, .unknown: + false + } + case .explicit(let kinds): + kinds.contains(where: source.matches(sourceKind:)) + } + } + } + + let workspacePaths: Set? + switch request.cwd { + case .paths(let paths): + workspacePaths = Set(paths) + case nil: + workspacePaths = nil + } + let sourceFilter = SourceFilter(request.sourceKinds) + + return snapshots.filter { snapshot in + if let workspacePaths { + guard let path = snapshot.workspace?.path, + workspacePaths.contains(path) + else { + return false + } + } + if let modelProviders = request.modelProviders, + modelProviders.isEmpty == false, + modelProviders.contains(snapshot.modelProvider ?? "") == false + { + return false + } + guard let source = snapshot.source else { + preconditionFailure("A stored-thread fixture must have an exact session source.") + } + guard sourceFilter.includes(source) else { + return false + } + if let searchTerm = request.searchTerm?.lowercased(), + searchTerm.isEmpty == false + { + let haystack = [ + snapshot.name, + snapshot.preview, + snapshot.workspace?.lastPathComponent, + ] + .compactMap { $0?.lowercased() } + .joined(separator: "\n") + guard haystack.contains(searchTerm) else { + return false + } + } + return true + } + } + + fileprivate static func sortedThreadSnapshots( + _ snapshots: [CodexThreadSnapshot], + sortKey: String?, + sortDirection: String? + ) throws -> [CodexThreadSnapshot] { + let rawKey = sortKey ?? CodexThreadSortKey.createdAt.rawValue + guard let key = CodexThreadSortKey(rawValue: rawKey) else { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "Unsupported test thread sort key \(rawKey)." + )) + } + let rawDirection = sortDirection ?? CodexSortDirection.descending.rawValue + guard let direction = CodexSortDirection(rawValue: rawDirection) else { + throw JSONRPC.Error.responseError(.init( + code: -32602, + message: "Unsupported test thread sort direction \(rawDirection)." + )) + } + let indexed = snapshots.enumerated().map { (offset: $0.offset, snapshot: $0.element) } + return indexed.sorted { lhs, rhs in + let lhsDate = threadSortDate(lhs.snapshot, key: key) + let rhsDate = threadSortDate(rhs.snapshot, key: key) + if lhsDate == rhsDate { + if key == .recencyAt { + switch direction { + case .ascending: + return lhs.snapshot.id.rawValue < rhs.snapshot.id.rawValue + case .descending: + return lhs.snapshot.id.rawValue > rhs.snapshot.id.rawValue + } + } + return lhs.offset < rhs.offset + } + switch direction { + case .ascending: + return compareOptionalDate(lhsDate, rhsDate, ascending: true) + case .descending: + return compareOptionalDate(lhsDate, rhsDate, ascending: false) + } + }.map(\.snapshot) + } + + private static func threadSortDate( + _ snapshot: CodexThreadSnapshot, + key: CodexThreadSortKey + ) -> Date? { + switch key { + case .createdAt: + snapshot.createdAt + case .updatedAt: + snapshot.updatedAt + case .recencyAt: + snapshot.recencyAt + } + } + + private static func compareOptionalDate( + _ lhs: Date?, + _ rhs: Date?, + ascending: Bool + ) -> Bool { + switch (lhs, rhs) { + case (.some(let lhs), .some(let rhs)): + return ascending ? lhs < rhs : lhs > rhs + case (nil, .some): + return ascending + case (.some, nil): + return ascending == false + case (nil, nil): + return false + } + } + + /// Enqueues a ChatGPT browser login response. + public func enqueueChatGPTLogin( + loginID: String, + authenticationURL: URL + ) throws { + try enqueue( + AppServerAPI.Account.Login.Response.chatgpt( + loginID: loginID, + authURL: authenticationURL.absoluteString + ), + for: "account/login/start" + ) + } + + public func enqueueChatGPTLoginCancellation( + _ status: CodexAppServerTestLoginCancellationStatus + ) throws { + try enqueue( + AppServerAPI.Account.Login.Cancel.Response(status: status.wireValue), + for: CodexAppServerTestOperation.accountLoginCancel.method + ) + } + + /// Enqueues a ChatGPT device-code login response. + public func enqueueChatGPTDeviceCodeLogin( + loginID: String, + verificationURL: URL, + userCode: String + ) throws { + try enqueue( + AppServerAPI.Account.Login.Response.chatgptDeviceCode( + loginID: loginID, + verificationURL: verificationURL.absoluteString, + userCode: userCode + ), + for: "account/login/start" + ) + } + + /// Enqueues an API-key login response. + public func enqueueAPIKeyLogin() throws { + try enqueue(AppServerAPI.Account.Login.Response.apiKey, for: "account/login/start") + } + + private static func semanticRequest( + from raw: JSONRPC.Request + ) throws -> CodexAppServerTestRequest { + let decoder = JSONDecoder() + guard let operation = CodexAppServerTestOperation(method: raw.method) else { + throw CodexAppServerTestError.invalidFixture( + "Unsupported semantic Testing request method \(raw.method)." + ) + } + switch operation { + case .initialize: + return .initialize + case .threadStart: + let params = try decoder.decode(AppServerAPI.Thread.Start.Params.self, from: raw.params) + guard let cwd = params.cwd else { + throw CodexAppServerTestError.invalidFixture( + "A recorded thread/start request requires cwd." + ) + } + return .threadStart( + workspace: URL(fileURLWithPath: cwd, isDirectory: true), + instructions: instructions(from: params), + options: threadOptions(from: params) + ) + case .threadResume: + let params = try decoder.decode(AppServerAPI.Thread.Resume.Params.self, from: raw.params) + guard let threadID = params.threadID else { + throw CodexAppServerTestError.invalidFixture( + "A recorded thread/resume request requires threadId." + ) + } + return .threadResume( + id: .init(rawValue: threadID), + options: threadOptions(from: params) + ) + case .threadFork: + let params = try decoder.decode(AppServerAPI.Thread.Fork.Params.self, from: raw.params) + guard let threadID = params.threadID else { + throw CodexAppServerTestError.invalidFixture( + "A recorded thread/fork request requires threadId." + ) + } + return .threadFork( + id: .init(rawValue: threadID), + options: threadOptions(from: params) + ) + case .threadList: + let params = try decoder.decode(AppServerAPI.Thread.List.Params.self, from: raw.params) + let workspaces: [URL]? + switch params.cwd { + case .paths(let paths): + workspaces = paths.map { URL(fileURLWithPath: $0, isDirectory: true) } + case nil: + workspaces = nil + } + return .threadList(.init( + archived: params.archived, + cursor: params.cursor, + workspaces: workspaces, + limit: params.limit, + searchTerm: params.searchTerm, + modelProviders: params.modelProviders, + sortDirection: params.sortDirection.flatMap(CodexSortDirection.init(rawValue:)), + sortKey: params.sortKey.flatMap(CodexThreadSortKey.init(rawValue:)), + sourceKinds: params.sourceKinds?.map(CodexThreadSourceKind.init(rawValue:)), + useStateDBOnly: params.useStateDbOnly + )) + case .threadRead: + let params = try decoder.decode(AppServerAPI.Thread.Read.Params.self, from: raw.params) + return .threadRead( + id: .init(rawValue: params.threadID), + includeTurns: params.includeTurns ?? false + ) + case .threadTurnsList: + let params = try decoder.decode( + AppServerAPI.Thread.Turns.List.Params.self, + from: raw.params + ) + return .threadTurnsList( + threadID: .init(rawValue: params.threadID), + query: .init( + cursor: params.cursor, + limit: params.limit, + sortDirection: params.sortDirection, + itemsLoadState: params.itemsLoadState + ) + ) + case .threadArchive: + let params = try decoder.decode(AppServerAPI.Thread.Archive.Params.self, from: raw.params) + return .threadArchive(.init(rawValue: params.threadID)) + case .threadUnarchive: + let params = try decoder.decode( + AppServerAPI.Thread.Unarchive.Params.self, + from: raw.params + ) + return .threadUnarchive(.init(rawValue: params.threadID)) + case .threadDelete: + let params = try decoder.decode(AppServerAPI.Thread.Delete.Params.self, from: raw.params) + return .threadDelete(.init(rawValue: params.threadID)) + case .threadRename: + let params = try decoder.decode(AppServerAPI.Thread.Name.Set.Params.self, from: raw.params) + return .threadRename(id: .init(rawValue: params.threadID), name: params.name) + case .threadCompact: + let params = try decoder.decode( + AppServerAPI.Thread.Compact.Start.Params.self, + from: raw.params + ) + return .threadCompact(.init(rawValue: params.threadID)) + case .threadRollback: + let params = try decoder.decode(AppServerAPI.Thread.Rollback.Params.self, from: raw.params) + return .threadRollback( + id: .init(rawValue: params.threadID), + numberOfTurns: params.numTurns + ) + case .turnStart: + let params = try decoder.decode(AppServerAPI.Turn.Start.Params.self, from: raw.params) + return .turnStart( + threadID: .init(rawValue: params.threadID), + prompt: prompt(from: params.input), + options: generationOptions(from: params) + ) + case .turnInterrupt: + let params = try decoder.decode(AppServerAPI.Turn.Interrupt.Params.self, from: raw.params) + return .turnInterrupt( + threadID: .init(rawValue: params.threadID), + turnID: .init(rawValue: params.turnID) + ) + case .reviewStart: + let params = try decoder.decode(AppServerAPI.Review.Start.Params.self, from: raw.params) + return .reviewStart( + threadID: .init(rawValue: params.threadID), + target: params.target, + delivery: params.delivery + ) + case .modelList: + let params = try decoder.decode(AppServerAPI.Model.List.Params.self, from: raw.params) + return .modelList(includeHidden: params.includeHidden ?? false) + case .accountRead: + let params = try decoder.decode(AppServerAPI.Account.Read.Params.self, from: raw.params) + return .accountRead(refreshToken: params.refreshToken) + case .accountRateLimitsRead: + return .accountRateLimitsRead + case .accountLoginStart: + return .accountLoginStart + case .accountLoginCancel: + let params = try decoder.decode(AppServerAPI.Account.Login.Cancel.Params.self, from: raw.params) + return .accountLoginCancel(.init(rawValue: params.loginID)) + case .accountLogout: + return .accountLogout + case .configurationRead: + return .configurationRead + case .configurationUpdate: + return .configurationUpdate(try configurationPatch(from: raw.params)) + } + } + + private static func instructions( + from params: AppServerAPI.Thread.Start.Params + ) -> CodexInstructions? { + guard params.baseInstructions != nil || params.developerInstructions != nil else { + return nil + } + return .init( + base: params.baseInstructions, + developer: params.developerInstructions + ) + } + + private static func threadOptions( + from params: AppServerAPI.Thread.Start.Params + ) -> CodexThread.Options { + let permissions: CodexThreadPermissions? + switch params.permissions { + case .profileID(let id): + permissions = .profile(id: id) + case .profileSelection(let selection): + permissions = .profileSelection(id: selection.id) + case nil: + permissions = nil + } + return .init( + model: params.model, + modelProvider: params.modelProvider, + approvalMode: approvalMode( + policy: params.approvalPolicy, + reviewer: params.approvalsReviewer + ), + sandbox: params.sandbox.flatMap(sandbox(from:)), + permissions: permissions, + serviceTier: params.serviceTier, + ephemeral: params.ephemeral, + config: params.config?.mapValues { codexJSONValue(from: $0) }, + personality: params.personality.map(CodexPersonality.init(rawValue:)), + serviceName: params.serviceName, + sessionStartSource: params.sessionStartSource.map { + switch $0 { + case .startup: .startup + case .clear: .clear + } + }, + threadSource: params.threadSource.map { + CodexThreadSource(rawValue: $0.rawValue) + } + ) + } + + private static func generationOptions( + from params: AppServerAPI.Turn.Start.Params + ) -> CodexGenerationOptions { + .init( + model: params.model, + approvalMode: approvalMode( + policy: params.approvalPolicy, + reviewer: params.approvalsReviewer + ), + sandbox: params.sandboxPolicy.map(sandbox(from:)), + cwd: params.cwd.map { URL(fileURLWithPath: $0, isDirectory: true) }, + effort: params.effort.map(CodexReasoningEffort.init(rawValue:)), + serviceTier: params.serviceTier, + summary: params.summary.map(CodexReasoningSummary.init(rawValue:)), + outputSchema: params.outputSchema.map(codexJSONValue(from:)), + personality: params.personality.map(CodexPersonality.init(rawValue:)), + clientUserMessageID: params.clientUserMessageID + ) + } + + private static func codexJSONValue( + from value: AppServerJSONValue + ) -> CodexJSONValue { + switch value { + case .string(let value): + .string(value) + case .int(let value): + .int(value) + case .double(let value): + .double(value) + case .bool(let value): + .bool(value) + case .array(let values): + .array(values.map { codexJSONValue(from: $0) }) + case .object(let values): + .object(values.mapValues { codexJSONValue(from: $0) }) + case .null: + .null + } + } + + private static func approvalMode( + policy: String?, + reviewer: String? + ) -> CodexApprovalMode? { + if policy == "never" { + return .denyAll + } + if policy == "on-request", reviewer == "auto_review" { + return .autoReview + } + return nil + } + + private static func sandbox(from value: String) -> CodexSandbox? { + switch value { + case "read-only": .readOnly + case "workspace-write": .workspaceWrite + case "danger-full-access": .fullAccess + default: nil + } + } + + private static func sandbox( + from value: AppServerAPI.Turn.SandboxPolicy + ) -> CodexSandbox { + switch value { + case .readOnly: .readOnly + case .workspaceWrite: .workspaceWrite + case .dangerFullAccess: .fullAccess + } + } + + private static func prompt(from input: [AppServerAPI.UserInput]) -> CodexPrompt { + .init(parts: input.compactMap { input -> CodexPrompt.Part? in + switch input { + case .text(let value): + .text(value) + case .image(let value): + URL(string: value).map(CodexPrompt.Part.imageURL) + case .localImage(let value): + .localImage(URL(fileURLWithPath: value)) + case .skill(let name, let path): + .skill(name: name, path: URL(fileURLWithPath: path)) + case .mention(let name, let path): + .mention(name: name, path: URL(fileURLWithPath: path)) + } + }) + } + + private struct RecordedConfigurationEdit: Decodable { + var keyPath: String + var value: CodexJSONValue + } + + private struct RecordedConfigurationUpdate: Decodable { + var edits: [RecordedConfigurationEdit] + } + + private static func configurationPatch(from data: Data) throws -> CodexConfigurationPatch { + let update = try JSONDecoder().decode(RecordedConfigurationUpdate.self, from: data) + var patch = CodexConfigurationPatch() + for edit in update.edits { + let value: String? + switch edit.value { + case .string(let string): + value = string + case .null: + value = nil + case .int, .double, .bool, .array, .object: + throw CodexAppServerTestError.invalidFixture( + "Unsupported recorded config value for \(edit.keyPath)." + ) + } + switch edit.keyPath { + case "review_model": + patch.setReviewModel(value) + case "model_reasoning_effort": + patch.setReasoningEffort(value.map(CodexReasoningEffort.init(rawValue:))) + case "service_tier": + patch.setServiceTier(value) + default: + throw CodexAppServerTestError.invalidFixture( + "Unsupported recorded config key \(edit.keyPath)." + ) + } + } + return patch + } + + /// Holds every request for `method` until `gate` opens. + package func hold(method: String, gate: CodexAppServerTestGate) { + gatesByMethod[method] = .init(gate: gate, ignoresCancellation: false) + } + + /// Holds the next request for `method` until `gate` opens. + package func holdNext(method: String, gate: CodexAppServerTestGate) { + oneShotGatesByMethod[method, default: []].append(.init( + gate: gate, + ignoresCancellation: false + )) + } + + /// Holds the next request for `method` and ignores task cancellation while waiting. + package func holdNextIgnoringCancellation(method: String, gate: CodexAppServerTestGate) { + oneShotGatesByMethod[method, default: []].append(.init( + gate: gate, + ignoresCancellation: true + )) + } + + /// Returns all requests sent so far. + public func holdNext( + _ operation: CodexAppServerTestOperation, + gate: CodexAppServerTestGate + ) { + holdNext(method: operation.method, gate: gate) + } + + public func holdNextIgnoringCancellation( + _ operation: CodexAppServerTestOperation, + gate: CodexAppServerTestGate + ) { + holdNextIgnoringCancellation(method: operation.method, gate: gate) + } + + public func recordedRequests() -> [CodexAppServerRecordedRequest] { + requests.enumerated().compactMap { offset, raw in + guard let request = try? Self.semanticRequest(from: raw) else { + return nil + } + return .init( + sequence: UInt64(offset + 1), + requestID: raw.id, + request: request, + method: raw.method, + params: raw.params + ) + } + } + + /// Returns all requests sent so far for `method`. + package func recordedRequests(method: String) -> [CodexAppServerRecordedRawRequest] { + requests.enumerated().compactMap { offset, raw in + guard raw.method == method else { + return nil + } + return .init( + sequence: UInt64(offset + 1), + requestID: raw.id, + method: raw.method, + params: raw.params + ) + } + } + + public func recordedRequests( + for operation: CodexAppServerTestOperation + ) -> [CodexAppServerRecordedRequest] { + recordedRequests().filter { $0.request.operation == operation } + } + + /// Returns all client notifications sent so far. + package func recordedNotifications() -> [CodexAppServerRecordedNotification] { + notifications.map { .init(method: $0.method, params: $0.params) } + } + + /// Suspends until at least `count` requests have been sent. + public func waitForRequestCount(_ count: Int) async { + if requests.count >= count { + return + } + await withCheckedContinuation { continuation in + if requests.count >= count { + continuation.resume() + } else { + requestCountWaiters.append((count, continuation)) + } + } + } + + /// Suspends until at least `count` requests for `method` have been sent. + package func waitForRequest(method: String, count: Int = 1) async { + if requests.filter({ $0.method == method }).count >= count { + return + } + await withCheckedContinuation { continuation in + if requests.filter({ $0.method == method }).count >= count { + continuation.resume() + } else { + requestMethodWaiters.append((method, count, continuation)) + } + } + } + + public func waitForRequest( + _ operation: CodexAppServerTestOperation, + count: Int = 1 + ) async { + await waitForRequest(method: operation.method, count: count) + } + + /// Suspends until at least `count` notification stream consumers are attached. + public func waitForNotificationStreamCount(_ count: Int) async { + let consumerCount = hasInboundConsumer ? 1 : 0 + if consumerCount >= count { + return + } + await withCheckedContinuation { continuation in + let consumerCount = hasInboundConsumer ? 1 : 0 + if consumerCount >= count { + continuation.resume() + } else { + notificationStreamCountWaiters.append((count, continuation)) + } + } + } + + /// Returns the maximum number of in-flight requests observed for `method`. + package func maxActiveCount(for method: String) -> Int { + maxActiveByMethod[method] ?? 0 + } + + public func maxActiveCount(for operation: CodexAppServerTestOperation) -> Int { + maxActiveCount(for: operation.method) + } + + package func notificationStreamCount() -> Int { + hasInboundConsumer ? 1 : 0 + } + + package func isClosedForTesting() -> Bool { + closed + } + + /// Emits a server notification to all attached app-server notification streams. + package func emitServerNotification( + method: String, + params: Params + ) async throws { + let notification = JSONRPC.Notification( + method: method, + params: try JSONEncoder().encode(params) + ) + try await mailbox.send(JSONRPC.notificationFrame(notification)) + } + + /// Emits a server notification from a raw JSON object string. + package func emitServerNotificationJSON(method: String, json: String) async throws { + let notification = JSONRPC.Notification( + method: method, + params: Data(json.utf8) + ) + try await mailbox.send(JSONRPC.notificationFrame(notification)) + } + + package func emitServerNotification(method: String, params: Data) async throws { + try await mailbox.send(JSONRPC.notificationFrame(.init( + method: method, + params: params + ))) + } + + package func emitServerRequest( + id: CodexServerRequestID, + method: String, + params: Data + ) async throws { + try await mailbox.send(JSONRPC.serverRequestFrame( + id: id, + method: method, + params: params + )) + } + + package func emitRawInboundFrame(_ frame: Data) async throws { + try await mailbox.send(frame) + } + + package func inboundMailboxSnapshot() async -> JSONRPCInboundFrameMailbox.Snapshot { + await mailbox.snapshot() + } + + package func holdNextInboundEventDelivery(at gate: CodexAppServerTestGate) { + precondition( + inboundEventDeliveryGate == nil, + "Only one inbound event delivery can be held at a time." + ) + inboundEventDeliveryGate = gate + } + + package func waitUntilInboundEventDeliveryIsHeld() async { + guard isHoldingInboundEventDelivery == false else { + return + } + await withCheckedContinuation { continuation in + inboundEventDeliveryHeldWaiters.append(continuation) + } + } + + package func recordedServerRequestResponses() -> [CodexAppServerTestServerResponse] { + serverRequestResponses + } + + package func serverRequestResponse( + for id: CodexServerRequestID + ) async -> CodexServerRequestResponse? { + if let response = serverRequestResponses.last(where: { $0.requestID == id }) { + return response.response + } + return await withCheckedContinuation { continuation in + serverRequestResponseWaiters[id, default: []].append(continuation) + } + } + + package func finishServerRequestWithoutResponse(_ id: CodexServerRequestID) { + let waiters = serverRequestResponseWaiters.removeValue(forKey: id) ?? [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + package func finishAllServerRequestsWithoutResponse() { + let waiters = serverRequestResponseWaiters.values.flatMap { $0 } + serverRequestResponseWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + /// Finishes all attached notification streams with `error`. + package func finishNotificationStreams(throwing error: any Error) async { + let failure = (error as? CodexTransportFailure) ?? .io( + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + ) + await mailbox.finish(throwing: failure) + } + + public func failConnection(_ failure: CodexTransportFailure) async { + await mailbox.finish(throwing: failure) + } + + package func enqueueInitialize(codexHome: String?, userAgent: String?) throws { + try enqueue( + AppServerAPI.Initialize.Response(codexHome: codexHome, userAgent: userAgent), + for: "initialize" + ) + } + + private func dequeueResponse(for method: String) -> QueuedResponse? { + guard var queued = responses[method], queued.isEmpty == false else { + return nil + } + let response = queued.removeFirst() + responses[method] = queued + return response + } + + private func dequeueOneShotGate(for method: String) -> RequestGate? { + guard var gates = oneShotGatesByMethod[method], gates.isEmpty == false else { + return nil + } + let gate = gates.removeFirst() + oneShotGatesByMethod[method] = gates + return gate + } + + private func resumeRequestCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in requestCountWaiters { + if requests.count >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + requestCountWaiters = remaining + } + + private func resumeRequestMethodWaiters() { + var remaining: [(String, Int, CheckedContinuation)] = [] + for waiter in requestMethodWaiters { + let count = requests.filter { $0.method == waiter.0 }.count + if count >= waiter.1 { + waiter.2.resume() + } else { + remaining.append(waiter) + } + } + requestMethodWaiters = remaining + } + + private func resumeNotificationStreamCountWaiters() { + var remaining: [(Int, CheckedContinuation)] = [] + for waiter in notificationStreamCountWaiters { + if (hasInboundConsumer ? 1 : 0) >= waiter.0 { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + notificationStreamCountWaiters = remaining + } + + private static func window( + from window: CodexRateLimitWindow + ) -> AppServerAPI.Account.RateLimits.Window { + .init( + usedPercent: window.usedPercent, + windowDurationMins: window.windowDurationMinutes, + resetsAt: window.resetsAt.map { Int64($0.timeIntervalSince1970) } + ) + } +} + +extension CodexAppServerTestTransport: JSONRPC.Transport { + package func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + try Task.checkCancellation() + guard closed == false else { + throw JSONRPC.Error.closed + } + try acceptWrite() + + let responseWaiter = JSONRPCResponseWaiter() + pendingResponses[request.id] = responseWaiter + requests.append(request) + resumeRequestCountWaiters() + resumeRequestMethodWaiters() + activeByMethod[request.method, default: 0] += 1 + maxActiveByMethod[request.method] = max( + maxActiveByMethod[request.method] ?? 0, + activeByMethod[request.method] ?? 0 + ) + let queuedResponse = dequeueResponse(for: request.method) + if let gate = dequeueOneShotGate(for: request.method) ?? gatesByMethod[request.method] { + activeRequestGatesByRequestID[request.id] = gate + try await gate.wait() + activeRequestGatesByRequestID.removeValue(forKey: request.id) + } + activeByMethod[request.method, default: 1] -= 1 + + let result: Result + do { + guard closed == false else { + return try await responseWaiter.wait() + } + if let queuedResponse { + switch queuedResponse { + case .success(let data): + result = .success(data) + case .failure(let error): + result = .failure(error) + } + } else if let responseHandler = responseHandlers[request.method] { + do { + result = .success(try await responseHandler(request.params)) + } catch let error as JSONRPC.Error { + result = .failure(error) + } + } else { + pendingResponses.removeValue(forKey: request.id) + throw CodexTransportFailure.contractViolation( + message: "No test response is configured for \(request.method)." + ) + } + let frame = try JSONRPC.responseFrame(id: request.id, result: result) + try await mailbox.send(frame) + } catch is CancellationError { + pendingResponses.removeValue(forKey: request.id) + throw CancellationError() + } catch let failure as CodexTransportFailure { + if case .contractViolation = failure { + pendingResponses.removeValue(forKey: request.id) + throw failure + } + await claimTerminal(failure) + } catch { + await claimTerminal(.io( + errno: (error as? POSIXError)?.code.rawValue, + message: error.localizedDescription + )) + } + return try await responseWaiter.wait() + } + + package func notify(_ notification: JSONRPC.Notification) async throws { + guard closed == false else { + throw JSONRPC.Error.closed + } + notifications.append(notification) + } + + package func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + if hasInboundConsumer == false { + hasInboundConsumer = true + resumeNotificationStreamCountWaiters() + } + while true { + let frame: Data + do { + guard let next = try await mailbox.next() else { + inboundTerminalObserved = true + return nil + } + frame = next + } catch { + let snapshot = await mailbox.snapshot() + if snapshot.isTerminal, snapshot.acceptedFrameCount == 0 { + inboundTerminalObserved = true + } + throw error + } + if let gate = inboundEventDeliveryGate { + isHoldingInboundEventDelivery = true + let waiters = inboundEventDeliveryHeldWaiters + inboundEventDeliveryHeldWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + await gate.waitIgnoringCancellation() + inboundEventDeliveryGate = nil + isHoldingInboundEventDelivery = false + } + switch try JSONRPC.decodeInboundEnvelope(frame) { + case .response(let id, let result): + guard let waiter = pendingResponses.removeValue(forKey: id) else { + if closed { + connectionEventHub.yield(.warning( + ConnectionDiagnosticFactory.lateResponse(requestID: id) + )) + continue + } + let failure = CodexTransportFailure.protocolViolation( + message: "Received a JSON-RPC response for unknown request id \(id).", + rawData: frame + ) + await claimTerminal(failure) + throw failure + } + waiter.resolve(result) + case .event(let event): + return event + } + } + } + + package func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) throws { + guard closed == false else { + throw JSONRPC.Error.closed + } + serverRequestResponses.append(.init(requestID: requestID, response: response)) + let waiters = serverRequestResponseWaiters.removeValue(forKey: requestID) ?? [] + for waiter in waiters { + waiter.resume(returning: response) + } + } + + package func beginClose() async -> JSONRPC.ProcessExitObservation? { + guard closeStarted == false else { + return nil + } + closeStarted = true + closed = true + let requestGates = Array(activeRequestGatesByRequestID.values) + + Array(gatesByMethod.values) + + oneShotGatesByMethod.values.flatMap { $0 } + let inboundEventDeliveryGate = inboundEventDeliveryGate + activeRequestGatesByRequestID.removeAll(keepingCapacity: false) + gatesByMethod.removeAll(keepingCapacity: false) + oneShotGatesByMethod.removeAll(keepingCapacity: false) + for requestGate in requestGates { + await requestGate.gate.close() + } + await inboundEventDeliveryGate?.close() + await mailbox.finish() + finishAllServerRequestsWithoutResponse() + return nil + } + + package func finishPendingResponsesAfterInboundDrain( + _ failure: CodexTransportFailure + ) { + precondition(inboundTerminalObserved) + let responseFailure: JSONRPC.Error = switch failure { + case .closed: .closed + case .io, .framing, .protocolViolation, .contractViolation: + .invalidMessage(failure.localizedDescription) + } + let waiters = pendingResponses.values + pendingResponses.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resolve(.failure(responseFailure)) + } + } + + package func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { + .unavailable + } + + package func waitUntilClosed() async {} + + package func reapProcess() async {} + + private func claimTerminal(_ failure: CodexTransportFailure) async { + closed = true + await mailbox.finish(throwing: failure) + } + + public func close() async { + _ = await beginClose() + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestServerRequestInjector.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestServerRequestInjector.swift new file mode 100644 index 00000000..b5076d82 --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestServerRequestInjector.swift @@ -0,0 +1,134 @@ +import Foundation +import Synchronization + +import CodexAppServerKit + +package struct CodexAppServerTestServerResponse: Equatable, Sendable { + package var requestID: CodexServerRequestID + package var response: CodexServerRequestResponse +} + +package actor CodexAppServerTestServerRequestInjector { + private let transport: CodexAppServerTestTransport + private let harness: CodexAppServerTestConnectionHarness + private let diagnosticRecorder: CodexAppServerTestServerRequestDiagnostics + private var injectedRequestCount = 0 + + package init( + clock: CodexAppServerClock = .init(), + handler: CodexAppServerRequestHandler? = nil + ) async { + let transport = CodexAppServerTestTransport() + let diagnostics = CodexAppServerTestServerRequestDiagnostics() + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + clock: clock, + handler: handler, + diagnosticHandler: { diagnostic in + diagnostics.record(diagnostic) + } + ) + self.transport = transport + self.harness = harness + self.diagnosticRecorder = diagnostics + } + + package func inject( + id: CodexServerRequestID, + method: String, + params: Data + ) async throws { + try await transport.emitServerRequest(id: id, method: method, params: params) + injectedRequestCount += 1 + await harness.connection.waitForServerRequestReceiveCount( + atLeast: injectedRequestCount + ) + } + + package func injectAccepted( + id: CodexServerRequestID, + method: String, + params: Data + ) async throws { + try await transport.emitServerRequest(id: id, method: method, params: params) + injectedRequestCount += 1 + } + + package func holdNextInboundEventDelivery(at gate: CodexAppServerTestGate) async { + await transport.holdNextInboundEventDelivery(at: gate) + } + + package func waitUntilInboundEventDeliveryIsHeld() async { + await transport.waitUntilInboundEventDeliveryIsHeld() + } + + package func waitUntilInjectedRequestsAreDelivered() async { + await harness.connection.waitForServerRequestReceiveCount( + atLeast: injectedRequestCount + ) + } + + package func resolve(_ id: CodexServerRequestID) async throws { + let params = try JSONEncoder().encode( + CodexAppServerTestResolvedNotification( + threadID: "testing", + requestID: id + ) + ) + try await transport.emitServerNotification( + method: "serverRequest/resolved", + params: params + ) + await harness.connection.waitUntilServerRequestsIdle() + await transport.finishServerRequestWithoutResponse(id) + } + + package func close() async { + await harness.close() + await transport.finishAllServerRequestsWithoutResponse() + } + + package func response( + for id: CodexServerRequestID + ) async -> CodexServerRequestResponse? { + await transport.serverRequestResponse(for: id) + } + + package func responses() async -> [CodexAppServerTestServerResponse] { + await transport.recordedServerRequestResponses() + } + + package func diagnostics() -> [ServerRequestRegistry.Diagnostic] { + diagnosticRecorder.values() + } + + package func childCount() async -> Int { + await harness.supervisor.serverRequestChildCount() + } + + package func waitUntilIdle() async { + await harness.connection.waitUntilServerRequestsIdle() + } +} + +private struct CodexAppServerTestResolvedNotification: Encodable { + var threadID: String + var requestID: CodexServerRequestID + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case requestID = "requestId" + } +} + +private final class CodexAppServerTestServerRequestDiagnostics: Sendable { + private let storage = Mutex<[ServerRequestRegistry.Diagnostic]>([]) + + func record(_ diagnostic: ServerRequestRegistry.Diagnostic) { + storage.withLock { $0.append(diagnostic) } + } + + func values() -> [ServerRequestRegistry.Diagnostic] { + storage.withLock { $0 } + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestStoredThread.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestStoredThread.swift new file mode 100644 index 00000000..643881ff --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestStoredThread.swift @@ -0,0 +1,954 @@ +import CodexAppServerKit +import Foundation + +public enum CodexAppServerTestSessionSource: Equatable, Sendable { + case cli + case vscode + case exec + case appServer + case custom(String) + case subAgentReview + case subAgentCompact + case subAgentThreadSpawn( + parentThreadID: CodexThreadID, + depth: Int, + agentPath: String?, + agentNickname: String?, + agentRole: String? + ) + case subAgentMemoryConsolidation + case subAgentOther(String) + case unknown + + /// The legacy single source-kind projection used by snapshot fixtures. + /// Custom sources project to `.unknown`; use ``filterSourceKind`` when + /// constructing app-server source-kind filters. + public var sourceKind: CodexThreadSourceKind { + filterSourceKind ?? .unknown + } + + /// The exact source-kind filter leaf, or `nil` when no filter includes this source. + public var filterSourceKind: CodexThreadSourceKind? { + domainProjection.sourceKind + } + + package var domainProjection: CodexThreadSessionSource { + switch self { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .custom(let value): + .custom(value) + case .subAgentReview: + .subAgent(.review) + case .subAgentCompact: + .subAgent(.compact) + case .subAgentThreadSpawn( + let parentThreadID, + let depth, + let agentPath, + let agentNickname, + let agentRole + ): + .subAgent(.threadSpawn(.init( + parentThreadID: parentThreadID, + depth: depth, + agentPath: agentPath, + agentNickname: agentNickname, + agentRole: agentRole + ))) + case .subAgentMemoryConsolidation: + .subAgent(.memoryConsolidation) + case .subAgentOther(let value): + .subAgent(.other(value)) + case .unknown: + .unknown + } + } + + package var wireValue: CodexJSONValue { + do { + return try JSONDecoder().decode( + CodexJSONValue.self, + from: JSONEncoder().encode(appServerValue) + ) + } catch { + preconditionFailure("A validated Testing session source must encode: \(error)") + } + } + + package var appServerValue: AppServerAPI.Thread.SessionSource { + switch self { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .custom(let value): + .custom(value) + case .subAgentReview: + .subAgent(.review) + case .subAgentCompact: + .subAgent(.compact) + case .subAgentThreadSpawn( + let parentThreadID, + let depth, + let agentPath, + let agentNickname, + let agentRole + ): + .subAgent(.threadSpawn(.init( + parentThreadID: parentThreadID.rawValue, + depth: depth, + agentPath: agentPath, + agentNickname: agentNickname, + agentRole: agentRole + ))) + case .subAgentMemoryConsolidation: + .subAgent(.memoryConsolidation) + case .subAgentOther(let value): + .subAgent(.other(value)) + case .unknown: + .unknown + } + } + + package func validateFixture() throws { + switch self { + case .custom(let value): + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + value, + field: "custom thread session source" + ) + case .subAgentThreadSpawn( + let parentThreadID, + let depth, + let agentPath, + let agentNickname, + let agentRole + ): + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + parentThreadID.rawValue, + field: "sub-agent parent thread id" + ) + guard depth >= 0 else { + throw CodexAppServerTestError.invalidFixture( + "sub-agent thread-spawn depth must not be negative" + ) + } + for (value, field) in [ + (agentPath, "sub-agent path"), + (agentNickname, "sub-agent nickname"), + (agentRole, "sub-agent role"), + ] { + if let value { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + value, + field: field + ) + } + } + case .subAgentOther(let value): + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + value, + field: "other sub-agent source" + ) + case .cli, .vscode, .exec, .appServer, .subAgentReview, .subAgentCompact, + .subAgentMemoryConsolidation, .unknown: + break + } + } +} + +public struct CodexAppServerTestThreadMetadata: Equatable, Sendable { + public enum HistoryMode: Equatable, Sendable { + case legacy + case paginated + } + + public var sessionID: String + public var forkedFromID: CodexThreadID? + public var parentThreadID: CodexThreadID? + public var cliVersion: String + public var source: CodexAppServerTestSessionSource + public var gitInfo: CodexThreadGitInfo? + public var historyMode: HistoryMode + + public init( + sessionID: String, + forkedFromID: CodexThreadID? = nil, + parentThreadID: CodexThreadID? = nil, + cliVersion: String, + source: CodexAppServerTestSessionSource, + gitInfo: CodexThreadGitInfo? = nil, + historyMode: HistoryMode = .legacy + ) { + self.sessionID = sessionID + self.forkedFromID = forkedFromID + self.parentThreadID = parentThreadID + self.cliVersion = cliVersion + self.source = source + self.gitInfo = gitInfo + self.historyMode = historyMode + } +} + +public struct CodexAppServerTestThreadRuntimeMetadata: Equatable, Sendable { + public enum ApprovalPolicy: Equatable, Sendable { + case unlessTrusted + case onRequest + case granular( + sandboxApproval: Bool, + rules: Bool, + skillApproval: Bool, + requestPermissions: Bool, + mcpElicitations: Bool + ) + case never + } + + public enum ApprovalsReviewer: Equatable, Sendable { + case user + case autoReview + } + + public enum NetworkAccess: Equatable, Sendable { + case restricted + case enabled + } + + public enum SandboxPolicy: Equatable, Sendable { + case dangerFullAccess + case readOnly(networkAccess: Bool) + case externalSandbox(networkAccess: NetworkAccess) + case workspaceWrite( + writableRoots: [URL], + networkAccess: Bool, + excludeTmpdirEnvVar: Bool, + excludeSlashTmp: Bool + ) + } + + public struct ActivePermissionProfile: Equatable, Sendable { + public var id: String + public var extends: String? + + public init(id: String, extends: String? = nil) throws { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + id, + field: "active permission profile id" + ) + if let extends { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + extends, + field: "active permission profile parent id" + ) + } + self.id = id + self.extends = extends + } + } + + public enum MultiAgentMode: Equatable, Sendable { + case custom(String) + case explicitRequestOnly + case proactive + } + + public var model: String + public var modelProvider: String + public var serviceTier: String? + public var cwd: URL + public var runtimeWorkspaceRoots: [URL] + public var instructionSources: [URL] + public var approvalPolicy: ApprovalPolicy + public var approvalsReviewer: ApprovalsReviewer + public var sandbox: SandboxPolicy + public var activePermissionProfile: ActivePermissionProfile? + public var reasoningEffort: CodexReasoningEffort? + public var multiAgentMode: MultiAgentMode + + public init( + model: String, + modelProvider: String, + serviceTier: String?, + cwd: URL, + runtimeWorkspaceRoots: [URL], + instructionSources: [URL], + approvalPolicy: ApprovalPolicy, + approvalsReviewer: ApprovalsReviewer, + sandbox: SandboxPolicy, + activePermissionProfile: ActivePermissionProfile?, + reasoningEffort: CodexReasoningEffort?, + multiAgentMode: MultiAgentMode + ) throws { + try Self.validate( + model: model, + modelProvider: modelProvider, + cwd: cwd, + runtimeWorkspaceRoots: runtimeWorkspaceRoots, + instructionSources: instructionSources, + sandbox: sandbox, + reasoningEffort: reasoningEffort, + multiAgentMode: multiAgentMode + ) + self.model = model + self.modelProvider = modelProvider + self.serviceTier = serviceTier + self.cwd = cwd + self.runtimeWorkspaceRoots = runtimeWorkspaceRoots + self.instructionSources = instructionSources + self.approvalPolicy = approvalPolicy + self.approvalsReviewer = approvalsReviewer + self.sandbox = sandbox + self.activePermissionProfile = activePermissionProfile + self.reasoningEffort = reasoningEffort + self.multiAgentMode = multiAgentMode + } + + package var wireValue: CodexJSONValue { + .object([ + "model": .string(model), + "modelProvider": .string(modelProvider), + "serviceTier": serviceTier.map(CodexJSONValue.string) ?? .null, + "cwd": .string(cwd.standardizedFileURL.path), + "runtimeWorkspaceRoots": .array( + runtimeWorkspaceRoots.map { + .string($0.standardizedFileURL.path) + }), + "instructionSources": .array( + instructionSources.map { + .string($0.standardizedFileURL.path) + }), + "approvalPolicy": approvalPolicy.wireValue, + "approvalsReviewer": approvalsReviewer.wireValue, + "sandbox": sandbox.wireValue, + "activePermissionProfile": activePermissionProfile?.wireValue ?? .null, + "reasoningEffort": reasoningEffort.map { .string($0.rawValue) } ?? .null, + "multiAgentMode": multiAgentMode.wireValue, + ]) + } + + package func validateFixture() throws { + try Self.validate( + model: model, + modelProvider: modelProvider, + cwd: cwd, + runtimeWorkspaceRoots: runtimeWorkspaceRoots, + instructionSources: instructionSources, + sandbox: sandbox, + reasoningEffort: reasoningEffort, + multiAgentMode: multiAgentMode + ) + if let activePermissionProfile { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + activePermissionProfile.id, + field: "active permission profile id" + ) + if let extends = activePermissionProfile.extends { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + extends, + field: "active permission profile parent id" + ) + } + } + } + + private static func validate( + model: String, + modelProvider: String, + cwd: URL, + runtimeWorkspaceRoots: [URL], + instructionSources: [URL], + sandbox: SandboxPolicy, + reasoningEffort: CodexReasoningEffort?, + multiAgentMode: MultiAgentMode + ) throws { + try CodexAppServerTestThreadFixtureValidation.requireNonempty(model, field: "model") + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + modelProvider, + field: "model provider" + ) + try CodexAppServerTestThreadFixtureValidation.requireAbsoluteFileURL(cwd, field: "cwd") + try CodexAppServerTestThreadFixtureValidation.requireAbsoluteFileURLs( + runtimeWorkspaceRoots, + field: "runtime workspace root" + ) + try CodexAppServerTestThreadFixtureValidation.requireAbsoluteFileURLs( + instructionSources, + field: "instruction source" + ) + if case .workspaceWrite(let writableRoots, _, _, _) = sandbox { + try CodexAppServerTestThreadFixtureValidation.requireAbsoluteFileURLs( + writableRoots, + field: "sandbox writable root" + ) + } + if let reasoningEffort { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + reasoningEffort.rawValue, + field: "reasoning effort" + ) + } + if case .custom(let value) = multiAgentMode { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + value, + field: "custom multi-agent mode" + ) + } + } +} + +public struct CodexAppServerTestStoredThread: Equatable, Sendable { + public let snapshot: CodexThreadSnapshot + public let turns: [CodexAppServerTestTurn] + public let runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata + public let isArchived: Bool + package let metadata: CodexAppServerTestThreadMetadata + package let wireValue: CodexJSONValue + + public init( + snapshot: CodexThreadSnapshot, + turns: [CodexAppServerTestTurn], + metadata: CodexAppServerTestThreadMetadata, + runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata, + isArchived: Bool + ) throws { + let validated = try Self.validate( + snapshot: snapshot, + turns: turns, + metadata: metadata, + runtimeMetadata: runtimeMetadata + ) + let snapshot = Self.canonicalSnapshot(snapshot, metadata: metadata) + self.snapshot = snapshot + self.turns = turns + self.metadata = metadata + self.runtimeMetadata = runtimeMetadata + self.isArchived = isArchived + self.wireValue = Self.makeWireValue( + snapshot: snapshot, + turns: turns, + metadata: metadata, + validated: validated + ) + } + + public func replacingTurns( + _ turns: [CodexAppServerTestTurn] + ) throws -> Self { + let snapshot = CodexThreadSnapshot( + id: snapshot.id, + workspace: snapshot.workspace, + name: snapshot.name, + preview: snapshot.preview, + modelProvider: snapshot.modelProvider, + sessionID: snapshot.sessionID, + parentThreadID: snapshot.parentThreadID, + source: snapshot.source, + gitInfo: snapshot.gitInfo, + createdAt: snapshot.createdAt, + updatedAt: snapshot.updatedAt, + recencyAt: snapshot.recencyAt, + status: snapshot.status, + ephemeral: snapshot.ephemeral, + turns: turns.map(\.snapshot), + turnItemsAreAuthoritative: true, + presentFields: snapshot.presentFields.union([.turns]) + ) + return try Self( + snapshot: snapshot, + turns: turns, + metadata: metadata, + runtimeMetadata: runtimeMetadata, + isArchived: isArchived + ) + } + + public func replacingStatus(_ status: CodexThreadStatus) throws -> Self { + var snapshot = snapshot + snapshot.status = status + return try Self( + snapshot: snapshot, + turns: turns, + metadata: metadata, + runtimeMetadata: runtimeMetadata, + isArchived: isArchived + ) + } + + package func wireValue(includingTurns: Bool) -> CodexJSONValue { + guard includingTurns == false else { + return wireValue + } + guard case .object(var fields) = wireValue else { + preconditionFailure("A stored-thread fixture must own an object wire value.") + } + fields["turns"] = .array([]) + return .object(fields) + } + + private static func canonicalSnapshot( + _ snapshot: CodexThreadSnapshot, + metadata: CodexAppServerTestThreadMetadata + ) -> CodexThreadSnapshot { + var snapshot = snapshot + snapshot.sessionID = metadata.sessionID + snapshot.parentThreadID = metadata.parentThreadID + snapshot.source = metadata.source.domainProjection + snapshot.gitInfo = metadata.gitInfo + snapshot.presentFields.remove(.sourceKind) + snapshot.presentFields.formUnion([ + .sessionID, + .parentThreadID, + .source, + .gitInfo, + ]) + return snapshot + } + + private struct ValidatedSnapshot { + var workspace: URL + var preview: String + var modelProvider: String + var createdAt: Int + var updatedAt: Int + var recencyAt: Int? + var status: CodexThreadStatus + var ephemeral: Bool + } + + private static func validate( + snapshot: CodexThreadSnapshot, + turns: [CodexAppServerTestTurn], + metadata: CodexAppServerTestThreadMetadata, + runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata + ) throws -> ValidatedSnapshot { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + snapshot.id.rawValue, + field: "thread id" + ) + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + metadata.sessionID, + field: "thread session id" + ) + if snapshot.hasField(.sessionID), snapshot.sessionID != metadata.sessionID { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot session id must match the Testing thread metadata" + ) + } + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + metadata.cliVersion, + field: "CLI version" + ) + if let forkedFromID = metadata.forkedFromID { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + forkedFromID.rawValue, + field: "forked-from thread id" + ) + } + if let parentThreadID = metadata.parentThreadID { + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + parentThreadID.rawValue, + field: "parent thread id" + ) + } + if case .subAgentThreadSpawn(let sourceParentThreadID, _, _, _, _) = metadata.source, + metadata.parentThreadID != sourceParentThreadID + { + throw CodexAppServerTestError.invalidFixture( + "thread-spawn source parent must match the Testing thread metadata parent" + ) + } + if snapshot.hasField(.parentThreadID), + snapshot.parentThreadID != metadata.parentThreadID + { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot parent id must match the Testing thread metadata" + ) + } + try metadata.source.validateFixture() + guard let workspace = snapshot.workspace else { + throw CodexAppServerTestError.invalidFixture("thread cwd is required") + } + try CodexAppServerTestThreadFixtureValidation.requireAbsoluteFileURL( + workspace, + field: "thread cwd" + ) + guard let preview = snapshot.preview else { + throw CodexAppServerTestError.invalidFixture("thread preview is required") + } + guard let modelProvider = snapshot.modelProvider else { + throw CodexAppServerTestError.invalidFixture("thread model provider is required") + } + try CodexAppServerTestThreadFixtureValidation.requireNonempty( + modelProvider, + field: "thread model provider" + ) + if snapshot.hasField(.source) { + guard snapshot.source == metadata.source.domainProjection else { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot source must match the Testing thread metadata" + ) + } + } else if snapshot.hasField(.sourceKind) { + guard snapshot.sourceKind == metadata.source.sourceKind else { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot source must match the Testing thread metadata" + ) + } + } + if snapshot.hasField(.gitInfo), snapshot.gitInfo != metadata.gitInfo { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot Git metadata must match the Testing thread metadata" + ) + } + guard let createdAt = snapshot.createdAt else { + throw CodexAppServerTestError.invalidFixture("thread creation time is required") + } + guard let updatedAt = snapshot.updatedAt else { + throw CodexAppServerTestError.invalidFixture("thread update time is required") + } + guard let status = snapshot.status else { + throw CodexAppServerTestError.invalidFixture("thread status is required") + } + if case .unknown(let rawValue) = status { + throw CodexAppServerTestError.invalidFixture( + "unsupported thread status \(rawValue)" + ) + } + if case .active(let activeFlags) = status, + activeFlags.contains(where: { Self.supportedActiveFlags.contains($0) == false }) + { + throw CodexAppServerTestError.invalidFixture( + "thread active flags must use pinned current-v2 values" + ) + } + guard let ephemeral = snapshot.ephemeral else { + throw CodexAppServerTestError.invalidFixture("thread ephemeral flag is required") + } + let turnSnapshots = turns.map(\.snapshot) + guard let snapshotTurns = snapshot.turns, snapshotTurns == turnSnapshots else { + throw CodexAppServerTestError.invalidFixture( + "thread snapshot turns must match the Testing turn projections" + ) + } + guard Set(turnSnapshots.map(\.id)).count == turnSnapshots.count else { + throw CodexAppServerTestError.invalidFixture("thread turn ids must be unique") + } + try runtimeMetadata.validateFixture() + guard runtimeMetadata.modelProvider == modelProvider else { + throw CodexAppServerTestError.invalidFixture( + "runtime model provider must match the thread snapshot" + ) + } + guard runtimeMetadata.cwd.standardizedFileURL.path == workspace.standardizedFileURL.path else { + throw CodexAppServerTestError.invalidFixture( + "runtime cwd must match the thread snapshot" + ) + } + return try .init( + workspace: workspace, + preview: preview, + modelProvider: modelProvider, + createdAt: CodexAppServerTestThreadFixtureValidation.seconds( + since1970: createdAt, + field: "thread creation time" + ), + updatedAt: CodexAppServerTestThreadFixtureValidation.seconds( + since1970: updatedAt, + field: "thread update time" + ), + recencyAt: try snapshot.recencyAt.map { + try CodexAppServerTestThreadFixtureValidation.seconds( + since1970: $0, + field: "thread recency time" + ) + }, + status: status, + ephemeral: ephemeral + ) + } + + private static func makeWireValue( + snapshot: CodexThreadSnapshot, + turns: [CodexAppServerTestTurn], + metadata: CodexAppServerTestThreadMetadata, + validated: ValidatedSnapshot + ) -> CodexJSONValue { + .object([ + "id": .string(snapshot.id.rawValue), + "sessionId": .string(metadata.sessionID), + "forkedFromId": metadata.forkedFromID.map { + .string($0.rawValue) + } ?? .null, + "parentThreadId": metadata.parentThreadID.map { + .string($0.rawValue) + } ?? .null, + "preview": .string(validated.preview), + "ephemeral": .bool(validated.ephemeral), + "historyMode": metadata.historyMode.wireValue, + "modelProvider": .string(validated.modelProvider), + "createdAt": .int(validated.createdAt), + "updatedAt": .int(validated.updatedAt), + "recencyAt": validated.recencyAt.map(CodexJSONValue.int) ?? .null, + "status": validated.status.wireValue, + "path": .null, + "cwd": .string(validated.workspace.standardizedFileURL.path), + "cliVersion": .string(metadata.cliVersion), + "source": metadata.source.wireValue, + "threadSource": .null, + "agentNickname": .null, + "agentRole": .null, + "gitInfo": metadata.gitInfo.map(\.testWireValue) ?? .null, + "name": snapshot.name.map(CodexJSONValue.string) ?? .null, + "turns": .array(turns.map(\.wireValue)), + ]) + } + + private static let supportedActiveFlags: Set = [ + .waitingOnApproval, + .waitingOnUserInput, + ] +} + +private extension CodexThreadGitInfo { + var testWireValue: CodexJSONValue { + .object([ + "sha": sha.map(CodexJSONValue.string) ?? .null, + "branch": branch.map(CodexJSONValue.string) ?? .null, + "originUrl": originURL.map(CodexJSONValue.string) ?? .null, + ]) + } +} + +public struct CodexAppServerTestThreadPage: Equatable, Sendable { + public var threads: [CodexAppServerTestStoredThread] + public var nextCursor: String? + public var backwardsCursor: String? + + public init( + threads: [CodexAppServerTestStoredThread], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.threads = threads + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } + + package var wireValue: CodexJSONValue { + .object([ + "data": .array(threads.map { $0.wireValue(includingTurns: false) }), + "nextCursor": nextCursor.map(CodexJSONValue.string) ?? .null, + "backwardsCursor": backwardsCursor.map(CodexJSONValue.string) ?? .null, + ]) + } +} + +public struct CodexAppServerTestTurnPage: Equatable, Sendable { + public var turns: [CodexAppServerTestTurn] + public var nextCursor: String? + public var backwardsCursor: String? + + public init( + turns: [CodexAppServerTestTurn], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) { + self.turns = turns + self.nextCursor = nextCursor + self.backwardsCursor = backwardsCursor + } + + package var wireValue: CodexJSONValue { + .object([ + "data": .array(turns.map(\.wireValue)), + "nextCursor": nextCursor.map(CodexJSONValue.string) ?? .null, + "backwardsCursor": backwardsCursor.map(CodexJSONValue.string) ?? .null, + ]) + } +} + +private enum CodexAppServerTestThreadFixtureValidation { + static func requireNonempty(_ value: String, field: String) throws { + guard value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("\(field) must not be empty") + } + } + + static func requireAbsoluteFileURLs(_ urls: [URL], field: String) throws { + for url in urls { + try requireAbsoluteFileURL(url, field: field) + } + } + + static func requireAbsoluteFileURL(_ url: URL, field: String) throws { + guard url.isFileURL, url.path.hasPrefix("/") else { + throw CodexAppServerTestError.invalidFixture( + "\(field) must be an absolute file URL" + ) + } + } + + static func seconds(since1970 date: Date, field: String) throws -> Int { + let value = date.timeIntervalSince1970 + guard value.isFinite, + value >= Double(Int.min), + value <= Double(Int.max), + value.rounded(.towardZero) == value + else { + throw CodexAppServerTestError.invalidFixture( + "\(field) must be representable as whole Unix seconds" + ) + } + return Int(value) + } +} + +private extension CodexAppServerTestThreadMetadata.HistoryMode { + var wireValue: CodexJSONValue { + switch self { + case .legacy: + .string("legacy") + case .paginated: + .string("paginated") + } + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.ApprovalPolicy { + var wireValue: CodexJSONValue { + switch self { + case .unlessTrusted: + .string("untrusted") + case .onRequest: + .string("on-request") + case .granular( + let sandboxApproval, + let rules, + let skillApproval, + let requestPermissions, + let mcpElicitations + ): + .object([ + "granular": .object([ + "sandbox_approval": .bool(sandboxApproval), + "rules": .bool(rules), + "skill_approval": .bool(skillApproval), + "request_permissions": .bool(requestPermissions), + "mcp_elicitations": .bool(mcpElicitations), + ]) + ]) + case .never: + .string("never") + } + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.ApprovalsReviewer { + var wireValue: CodexJSONValue { + switch self { + case .user: + .string("user") + case .autoReview: + .string("auto_review") + } + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.SandboxPolicy { + var wireValue: CodexJSONValue { + switch self { + case .dangerFullAccess: + .object(["type": .string("dangerFullAccess")]) + case .readOnly(let networkAccess): + .object([ + "type": .string("readOnly"), + "networkAccess": .bool(networkAccess), + ]) + case .externalSandbox(let networkAccess): + .object([ + "type": .string("externalSandbox"), + "networkAccess": networkAccess.wireValue, + ]) + case .workspaceWrite( + let writableRoots, + let networkAccess, + let excludeTmpdirEnvVar, + let excludeSlashTmp + ): + .object([ + "type": .string("workspaceWrite"), + "writableRoots": .array( + writableRoots.map { + .string($0.standardizedFileURL.path) + }), + "networkAccess": .bool(networkAccess), + "excludeTmpdirEnvVar": .bool(excludeTmpdirEnvVar), + "excludeSlashTmp": .bool(excludeSlashTmp), + ]) + } + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.NetworkAccess { + var wireValue: CodexJSONValue { + switch self { + case .restricted: + .string("restricted") + case .enabled: + .string("enabled") + } + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.ActivePermissionProfile { + var wireValue: CodexJSONValue { + .object([ + "id": .string(id), + "extends": self.extends.map(CodexJSONValue.string) ?? .null, + ]) + } +} + +private extension CodexAppServerTestThreadRuntimeMetadata.MultiAgentMode { + var wireValue: CodexJSONValue { + switch self { + case .custom(let value): + .object(["custom": .string(value)]) + case .explicitRequestOnly: + .string("explicitRequestOnly") + case .proactive: + .string("proactive") + } + } +} + +private extension CodexThreadStatus { + var wireValue: CodexJSONValue { + switch self { + case .notLoaded: + .object(["type": .string("notLoaded")]) + case .idle: + .object(["type": .string("idle")]) + case .systemError: + .object(["type": .string("systemError")]) + case .active(let activeFlags): + .object([ + "type": .string("active"), + "activeFlags": .array(activeFlags.map { .string($0.rawValue) }), + ]) + case .unknown(let rawValue): + preconditionFailure("Unsupported Testing thread status \(rawValue) was not validated.") + } + } +} diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestTurn.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestTurn.swift new file mode 100644 index 00000000..0684b47f --- /dev/null +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestTurn.swift @@ -0,0 +1,152 @@ +import CodexAppServerKit +import Foundation + +public struct CodexAppServerTestTurn: Equatable, Sendable { + public let snapshot: CodexTurnSnapshot + public let items: [CodexAppServerTestItem] + package let wireValue: CodexJSONValue + + public init( + snapshot: CodexTurnSnapshot, + items: [CodexAppServerTestItem] + ) throws { + try Self.validate(snapshot: snapshot, items: items) + self.snapshot = snapshot + self.items = items + self.wireValue = Self.makeWireValue(snapshot: snapshot, items: items) + } + + public func replacingItems( + _ items: [CodexAppServerTestItem] + ) throws -> Self { + var snapshot = snapshot + snapshot.items = items.map(\.domainProjection) + return try Self(snapshot: snapshot, items: items) + } + + private static func validate( + snapshot: CodexTurnSnapshot, + items: [CodexAppServerTestItem] + ) throws { + guard snapshot.id.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture("turn id must not be empty") + } + let projections = items.map(\.domainProjection) + guard snapshot.items.count == projections.count, + zip(snapshot.items, projections).allSatisfy({ snapshotItem, projection in + snapshotItem.id == projection.id + && snapshotItem.kind == projection.kind + && snapshotItem.content == projection.content + }) else { + throw CodexAppServerTestError.invalidFixture( + "turn snapshot items must match the Testing item projections" + ) + } + if let startedAt = snapshot.startedAt, + let completedAt = snapshot.completedAt, + completedAt < startedAt { + throw CodexAppServerTestError.invalidFixture( + "turn completion must not precede its start" + ) + } + if let duration = snapshot.duration, duration < .zero { + throw CodexAppServerTestError.invalidFixture("turn duration must not be negative") + } + if snapshot.state == .inProgress, + snapshot.completedAt != nil || snapshot.duration != nil { + throw CodexAppServerTestError.invalidFixture( + "an in-progress turn must not have completion timing" + ) + } + } + + private static func makeWireValue( + snapshot: CodexTurnSnapshot, + items: [CodexAppServerTestItem] + ) -> CodexJSONValue { + var fields: [String: CodexJSONValue] = [ + "id": .string(snapshot.id.rawValue), + "status": .string(snapshot.status.rawValue), + "itemsView": .string(snapshot.itemsLoadState.rawValue), + "items": .array(items.map(\.wireValue)), + ] + if let startedAt = snapshot.startedAt { + fields["startedAt"] = .int(Int(startedAt.timeIntervalSince1970)) + } + if let completedAt = snapshot.completedAt { + fields["completedAt"] = .int(Int(completedAt.timeIntervalSince1970)) + } + if let duration = snapshot.duration { + fields["durationMs"] = .int(duration.millisecondsForTestTurn) + } + if let error = snapshot.error { + fields["error"] = error.wireValueForTesting + } + return .object(fields) + } +} + +public enum CodexAppServerTestTurnOutcome { + public static func failed( + response: CodexResponse, + error: CodexTurnError + ) -> CodexTurnOutcome { + .failed(.init(response: response, error: error)) + } +} + +private extension Duration { + var millisecondsForTestTurn: Int { + let components = self.components + let milliseconds = components.seconds * 1_000 + + components.attoseconds / 1_000_000_000_000_000 + precondition(milliseconds >= 0 && milliseconds <= Int.max) + return Int(milliseconds) + } +} + +extension CodexTurnError { + package var wireValueForTesting: CodexJSONValue { + var fields: [String: CodexJSONValue] = ["message": .string(message)] + if let info { + fields["codexErrorInfo"] = info.wireValueForTestTurn + } + if let additionalDetails { + fields["additionalDetails"] = .string(additionalDetails) + } + return .object(fields) + } +} + +private extension CodexErrorInfo { + var wireValueForTestTurn: CodexJSONValue { + switch self { + case .contextWindowExceeded: .string("contextWindowExceeded") + case .sessionBudgetExceeded: .string("sessionBudgetExceeded") + case .usageLimitExceeded: .string("usageLimitExceeded") + case .serverOverloaded: .string("serverOverloaded") + case .cyberPolicy: .string("cyberPolicy") + case .internalServerError: .string("internalServerError") + case .unauthorized: .string("unauthorized") + case .badRequest: .string("badRequest") + case .threadRollbackFailed: .string("threadRollbackFailed") + case .sandboxError: .string("sandboxError") + case .other: .string("other") + case .unknown(let rawValue): .string(rawValue) + case .httpConnectionFailed(let status): + .object(["httpConnectionFailed": Self.httpStatusPayload(status)]) + case .responseStreamConnectionFailed(let status): + .object(["responseStreamConnectionFailed": Self.httpStatusPayload(status)]) + case .responseStreamDisconnected(let status): + .object(["responseStreamDisconnected": Self.httpStatusPayload(status)]) + case .responseTooManyFailedAttempts(let status): + .object(["responseTooManyFailedAttempts": Self.httpStatusPayload(status)]) + case .activeTurnNotSteerable(let turnKind): + .object(["activeTurnNotSteerable": .object(["turnKind": .string(turnKind)])]) + } + } + + private static func httpStatusPayload(_ status: UInt16?) -> CodexJSONValue { + .object(["httpStatusCode": status.map { .int(Int($0)) } ?? .null]) + } +} diff --git a/Sources/CodexDataKit/CodexAsyncStreamRelay.swift b/Sources/CodexDataKit/CodexAsyncStreamRelay.swift new file mode 100644 index 00000000..7194791a --- /dev/null +++ b/Sources/CodexDataKit/CodexAsyncStreamRelay.swift @@ -0,0 +1,95 @@ +import Foundation +import Synchronization + +final class CodexAsyncStreamRelay: Sendable { + private struct State { + var continuations: [UUID: AsyncStream.Continuation] = [:] + var isFinished = false + } + + private let state = Mutex(State()) + private let bufferingPolicy: AsyncStream.Continuation.BufferingPolicy + + init( + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded + ) { + self.bufferingPolicy = bufferingPolicy + } + + var hasContinuations: Bool { + state.withLock { state in + state.continuations.isEmpty == false + } + } + + func makeStream() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: bufferingPolicy) + let shouldFinish = state.withLock { state in + guard state.isFinished == false else { + return true + } + state.continuations[id] = pair.continuation + return false + } + if shouldFinish { + pair.continuation.finish() + return pair.stream + } + let owner = CodexAsyncStreamRelayWeakBox(self) + pair.continuation.onTermination = codexAsyncStreamRelayTermination(owner: owner, id: id) + return pair.stream + } + + func yield(_ element: Element) { + let continuations = state.withLock { state in + Array(state.continuations.values) + } + for continuation in continuations { + continuation.yield(element) + } + } + + fileprivate func removeStream(_ id: UUID) { + let continuation = state.withLock { state in + state.continuations.removeValue(forKey: id) + } + continuation?.finish() + } + + func finish() { + let continuations: [AsyncStream.Continuation] = state.withLock { state in + guard state.isFinished == false else { + return [] + } + state.isFinished = true + let continuations = Array(state.continuations.values) + state.continuations.removeAll(keepingCapacity: false) + return continuations + } + for continuation in continuations { + continuation.finish() + } + } + + deinit { + finish() + } +} + +private final class CodexAsyncStreamRelayWeakBox: @unchecked Sendable { + weak var value: CodexAsyncStreamRelay? + + init(_ value: CodexAsyncStreamRelay) { + self.value = value + } +} + +private func codexAsyncStreamRelayTermination( + owner: CodexAsyncStreamRelayWeakBox, + id: UUID +) -> @Sendable (AsyncStream.Continuation.Termination) -> Void { + { @Sendable _ in + owner.value?.removeStream(id) + } +} diff --git a/Sources/CodexDataKit/CodexChatObservation.swift b/Sources/CodexDataKit/CodexChatObservation.swift new file mode 100644 index 00000000..51f16765 --- /dev/null +++ b/Sources/CodexDataKit/CodexChatObservation.swift @@ -0,0 +1,826 @@ +import CodexAppServerKit +import Foundation +import Synchronization + +public enum CodexChatSnapshotReason: Equatable, Sendable { + case initial + case refresh + case includeTurnsUpgrade + case generationRestart + case bufferOverflow + case upstreamFailure +} + +public struct CodexChatObservationSnapshot: Equatable, Sendable { + public var thread: CodexThreadSnapshot + public var phase: CodexChatPhase + + public init(thread: CodexThreadSnapshot, phase: CodexChatPhase) { + self.thread = thread + self.phase = phase + } +} + +public struct CodexChatObservationEvent: Equatable, Sendable { + public enum Payload: Equatable, Sendable { + case snapshot(CodexChatObservationSnapshot, reason: CodexChatSnapshotReason) + case update(CodexChatUpdate) + } + + public let generation: UInt64 + public let sequence: UInt64 + public let payload: Payload + + public init(generation: UInt64, sequence: UInt64, payload: Payload) { + self.generation = generation + self.sequence = sequence + self.payload = payload + } +} + +public struct CodexChatUpdates: AsyncSequence, Sendable { + public typealias Element = CodexChatObservationEvent + + public struct AsyncIterator: AsyncIteratorProtocol { + fileprivate let channel: CodexChatObservationChannel + + public mutating func next() async -> CodexChatObservationEvent? { + await channel.next() + } + } + + private let channel: CodexChatObservationChannel + private let iteratorClaim: CodexChatObservationIteratorClaim + + package init(channel: CodexChatObservationChannel) { + self.channel = channel + self.iteratorClaim = CodexChatObservationIteratorClaim() + } + + public func makeAsyncIterator() -> AsyncIterator { + precondition( + iteratorClaim.claim(), + "CodexChatUpdates supports exactly one iterator; call observe() for another subscriber." + ) + return AsyncIterator(channel: channel) + } +} + +public final class CodexChatObservation { + public let chat: CodexChat + public let updates: CodexChatUpdates + + private enum CloseState { + case open + case closing([CheckedContinuation]) + case closed + } + + private let leaseID: UUID + // The stream must not outlive the context that applies its events. + private let modelContext: CodexModelContext + private let releaseSignal: ChatObservationReleaseSignal + private let closeState = Mutex(.open) + + package init( + chat: CodexChat, + updates: CodexChatUpdates, + leaseID: UUID, + modelContext: CodexModelContext, + releaseSignal: ChatObservationReleaseSignal + ) { + self.chat = chat + self.updates = updates + self.leaseID = leaseID + self.modelContext = modelContext + self.releaseSignal = releaseSignal + } + + public nonisolated(nonsending) func close() async { + let ownsRelease = closeState.withLock { state in + guard case .open = state else { return false } + state = .closing([]) + return true + } + guard ownsRelease else { + await waitUntilClosed() + return + } + let acknowledgement = ChatObservationReleaseAcknowledgement() + releaseSignal.release(leaseID, acknowledgement: acknowledgement) + await acknowledgement.wait() + let waiters = closeState.withLock { state -> [CheckedContinuation] in + guard case .closing(let waiters) = state else { + preconditionFailure("Observation close owner lost its close state.") + } + state = .closed + return waiters + } + for waiter in waiters { waiter.resume() } + } + + package func cancel() { + let shouldRelease = closeState.withLock { state in + guard case .open = state else { return false } + state = .closed + return true + } + guard shouldRelease else { return } + releaseSignal.release(leaseID) + } + + package var releaseSignalForTesting: ChatObservationReleaseSignal { + releaseSignal + } + + deinit { + let shouldRelease = closeState.withLock { state in + guard case .open = state else { return false } + state = .closed + return true + } + if shouldRelease { + releaseSignal.release(leaseID) + } + } + + private func waitUntilClosed() async { + await withCheckedContinuation { continuation in + let isClosed = closeState.withLock { state in + switch state { + case .open: + preconditionFailure("A close waiter requires an active close owner.") + case .closing(var waiters): + waiters.append(continuation) + state = .closing(waiters) + return false + case .closed: + return true + } + } + if isClosed { + continuation.resume() + } + } + } +} + +package final class ChatObservationReleaseAcknowledgement: Sendable { + private enum State { + case pending(CheckedContinuation?) + case completed + } + + private let state = Mutex(.pending(nil)) + + package init() {} + + package func wait() async { + await withCheckedContinuation { continuation in + let isCompleted = state.withLock { state in + switch state { + case .pending(nil): + state = .pending(continuation) + return false + case .pending(.some): + preconditionFailure("Observation release acknowledgement has one waiter.") + case .completed: + return true + } + } + if isCompleted { + continuation.resume() + } + } + } + + package func complete() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .pending(let continuation): + state = .completed + return continuation + case .completed: + return nil + } + } + continuation?.resume() + } + + package func isCompletedForTesting() -> Bool { + state.withLock { state in + if case .completed = state { return true } + return false + } + } +} + +package final class ChatObservationStartWaiter: Sendable { + private enum State { + case pending(CheckedContinuation?) + case resolved(cancelled: Bool) + } + + private let state = Mutex(.pending(nil)) + + package init() {} + + package func wait() async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let result = state.withLock { state -> Bool? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("Observation start waiter supports one caller.") + case .resolved(let cancelled): + return cancelled + } + } + if let cancelled = result { + if cancelled { + continuation.resume(throwing: CancellationError()) + } else { + continuation.resume() + } + } + } + } onCancel: { + resolve(cancelled: true) + } + } + + package func resolve(cancelled: Bool) { + let continuation = state.withLock { + state -> CheckedContinuation? in + switch state { + case .pending(let continuation): + state = .resolved(cancelled: cancelled) + return continuation + case .resolved: + return nil + } + } + if cancelled { + continuation?.resume(throwing: CancellationError()) + } else { + continuation?.resume() + } + } +} + +package final class ChatObservationStartOperation: Sendable { + private let task: Task + private let completion: ChatObservationStartCompletion + private let completionTask: Task + + package init( + operation: sending @escaping @isolated(any) @Sendable () async throws -> Output + ) { + let task = Task(operation: operation) + let completion = ChatObservationStartCompletion() + self.task = task + self.completion = completion + completionTask = Task { [task, completion] in + do { + completion.resolve(.success(try await task.value)) + } catch { + completion.resolve(.failure(error)) + } + } + } + + package func value() async throws -> Output { + try await completion.value() + } + + package func cancel() { + task.cancel() + } + + package func cancelAndWait() async { + task.cancel() + await completionTask.value + } +} + +private final class ChatObservationStartCompletion: Sendable { + private enum State: Sendable { + case pending([ChatObservationStartValueWaiter]) + case completed(Result) + } + + private let state = Mutex(.pending([])) + + func value() async throws -> Output { + let waiter = ChatObservationStartValueWaiter() + let result = state.withLock { state -> Result? in + switch state { + case .pending(var waiters): + waiters.append(waiter) + state = .pending(waiters) + return nil + case .completed(let result): + return result + } + } + if let result { + waiter.resolve(result) + } + return try await waiter.value() + } + + func resolve(_ result: Result) { + let waiters = state.withLock { state -> [ChatObservationStartValueWaiter] in + switch state { + case .pending(let waiters): + state = .completed(result) + return waiters + case .completed: + return [] + } + } + for waiter in waiters { + waiter.resolve(result) + } + } +} + +private final class ChatObservationStartValueWaiter: Sendable { + private enum State: Sendable { + case pending(CheckedContinuation?) + case resolved(Result) + } + + private let state = Mutex(.pending(nil)) + + func value() async throws -> Output { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let result = state.withLock { state -> Result? in + switch state { + case .pending(nil): + state = .pending(continuation) + return nil + case .pending(.some): + preconditionFailure("Observation start value supports one waiter.") + case .resolved(let result): + return result + } + } + if let result { + continuation.resume(with: result) + } + } + } onCancel: { + resolve(.failure(CancellationError())) + } + } + + func resolve(_ result: Result) { + let continuation = state.withLock { + state -> CheckedContinuation? in + switch state { + case .pending(let continuation): + state = .resolved(result) + return continuation + case .resolved: + return nil + } + } + continuation?.resume(with: result) + } +} + +package struct ChatObservationRelease: Sendable { + package let leaseID: UUID +} + +package final class ChatObservationReleaseSignal: Sendable { + private struct State { + var pending: [ChatObservationRelease] = [] + var waiter: CheckedContinuation? + var releasedLeaseIDs: Set = [] + var acknowledgementsByLeaseID: [UUID: [ChatObservationReleaseAcknowledgement]] = [:] + var receiverIsActive = false + var isTerminated = false + var receiverDidComplete = false + } + + private let state = Mutex(State()) + + package init() {} + + package func release( + _ leaseID: UUID, + acknowledgement: ChatObservationReleaseAcknowledgement? = nil + ) { + let action = state.withLock { state -> ( + CheckedContinuation?, + Bool, + Bool + ) in + guard state.isTerminated == false else { + return (nil, true, false) + } + if state.releasedLeaseIDs.contains(leaseID) { + guard var acknowledgements = state.acknowledgementsByLeaseID[leaseID] else { + return (nil, true, false) + } + if let acknowledgement { + acknowledgements.append(acknowledgement) + state.acknowledgementsByLeaseID[leaseID] = acknowledgements + } + return (nil, false, false) + } + state.releasedLeaseIDs.insert(leaseID) + state.acknowledgementsByLeaseID[leaseID] = acknowledgement.map { [$0] } ?? [] + let release = ChatObservationRelease(leaseID: leaseID) + if let waiter = state.waiter { + state.waiter = nil + return (waiter, false, true) + } + state.pending.append(release) + return (nil, false, true) + } + if action.1 { + acknowledgement?.complete() + } else if action.2 { + action.0?.resume(returning: .init(leaseID: leaseID)) + } + } + + package func acknowledge(_ leaseID: UUID) { + let acknowledgements = state.withLock { state in + state.acknowledgementsByLeaseID.removeValue(forKey: leaseID) ?? [] + } + for acknowledgement in acknowledgements { + acknowledgement.complete() + } + } + + package func next() async -> ChatObservationRelease? { + precondition(beginReceive(), "Observation release signal supports one receiver.") + defer { endReceive() } + return await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> ChatObservationRelease?? in + if state.pending.isEmpty == false { + return .some(state.pending.removeFirst()) + } + if state.isTerminated { + return .some(nil) + } + precondition(state.waiter == nil) + state.waiter = continuation + return nil + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } + + package func terminate() { + let waiter = state.withLock { state -> CheckedContinuation? in + guard state.isTerminated == false else { + return nil + } + state.isTerminated = true + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + return waiter + } + waiter?.resume(returning: nil) + } + + package func completeAllAcknowledgements() { + let acknowledgements = state.withLock { state in + let acknowledgements = state.acknowledgementsByLeaseID.values.flatMap { $0 } + state.acknowledgementsByLeaseID.removeAll(keepingCapacity: false) + state.receiverDidComplete = true + return acknowledgements + } + for acknowledgement in acknowledgements { + acknowledgement.complete() + } + } + + package func releasedLeaseCountForTesting() -> Int { + state.withLock { $0.releasedLeaseIDs.count } + } + + package func receiverDidCompleteForTesting() -> Bool { + state.withLock { $0.receiverDidComplete } + } + + private func beginReceive() -> Bool { + state.withLock { state in + guard state.receiverIsActive == false else { return false } + state.receiverIsActive = true + return true + } + } + + private func endReceive() { + state.withLock { state in + precondition(state.receiverIsActive) + state.receiverIsActive = false + } + } +} + +private final class CodexChatObservationIteratorClaim: Sendable { + private let claimed = Mutex(false) + + func claim() -> Bool { + claimed.withLock { claimed in + guard claimed == false else { + return false + } + claimed = true + return true + } + } +} + +package final class CodexChatObservationChannel: Sendable { + private enum Phase: Sendable { + case open + case finishing + case finished + case cancelled + } + + private struct State: Sendable { + var pending: [CodexChatObservationEvent] = [] + var waiter: CheckedContinuation? + var phase = Phase.open + var nextIsActive = false + var overflowCount = 0 + var latestAcceptedCursor: (generation: UInt64, sequence: UInt64)? + var didSendCancellation = false + } + + package static let capacity = 256 + private let state = Mutex(State()) + private let releaseSignal: ChatObservationReleaseSignal? + private let leaseID: UUID? + + package init( + releaseSignal: ChatObservationReleaseSignal? = nil, + leaseID: UUID? = nil + ) { + precondition((releaseSignal == nil) == (leaseID == nil)) + self.releaseSignal = releaseSignal + self.leaseID = leaseID + } + + package func yield( + _ event: CodexChatObservationEvent, + overflowSnapshot: CodexChatObservationEvent + ) { + yield([event], overflowSnapshot: overflowSnapshot) + } + + package func yield( + _ events: [CodexChatObservationEvent], + overflowSnapshot: CodexChatObservationEvent + ) { + guard events.isEmpty == false else { return } + let delivery = state.withLock { state -> ( + CheckedContinuation?, + CodexChatObservationEvent? + ) in + guard case .open = state.phase else { + return (nil, nil) + } + for event in events { + Self.accept(event, state: &state) + } + if let waiter = state.waiter { + state.waiter = nil + Self.enqueue( + Array(events.dropFirst()), + overflowSnapshot: overflowSnapshot, + state: &state + ) + return (waiter, events[0]) + } + Self.enqueue(events, overflowSnapshot: overflowSnapshot, state: &state) + return (nil, nil) + } + delivery.0?.resume(returning: delivery.1) + } + + package func seed(_ event: CodexChatObservationEvent) { + state.withLock { state in + precondition(state.pending.isEmpty && state.waiter == nil) + guard case .open = state.phase else { + return + } + Self.accept(event, state: &state) + state.pending = [event] + } + } + + package func supersedeAndFinish(with event: CodexChatObservationEvent) { + let delivery = state.withLock { state -> CheckedContinuation? in + guard case .open = state.phase else { + return nil + } + Self.accept(event, state: &state) + guard case .snapshot = event.payload else { + preconditionFailure("A terminal observation event must be a complete snapshot.") + } + state.pending = [event] + state.phase = .finishing + let waiter = state.waiter + state.waiter = nil + if waiter != nil { + state.pending.removeFirst() + } + return waiter + } + delivery?.resume(returning: event) + } + + package func finish() { + let waiter = state.withLock { state -> CheckedContinuation? in + switch state.phase { + case .open: + state.phase = state.pending.isEmpty ? .finished : .finishing + let waiter = state.waiter + state.waiter = nil + return waiter + case .finishing, .finished, .cancelled: + return nil + } + } + waiter?.resume(returning: nil) + } + + package func cancel() { + let action = state.withLock { state -> ( + CheckedContinuation?, + Bool + ) in + guard case .finished = state.phase else { + state.phase = .cancelled + state.pending.removeAll(keepingCapacity: false) + let waiter = state.waiter + state.waiter = nil + let shouldRelease = state.didSendCancellation == false + state.didSendCancellation = true + return (waiter, shouldRelease) + } + return (nil, false) + } + action.0?.resume(returning: nil) + if action.1, let releaseSignal, let leaseID { + releaseSignal.release(leaseID) + } + } + + fileprivate func next() async -> CodexChatObservationEvent? { + precondition(beginNext(), "CodexChatUpdates supports one in-flight next() call.") + defer { endNext() } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> CodexChatObservationEvent?? in + if state.pending.isEmpty == false { + let event = state.pending.removeFirst() + if state.pending.isEmpty, case .finishing = state.phase { + state.phase = .finished + } + return .some(.some(event)) + } + switch state.phase { + case .open: + precondition(state.waiter == nil) + state.waiter = continuation + return nil + case .finishing, .finished, .cancelled: + state.phase = .finished + return .some(nil) + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + cancel() + } + } + + package func overflowCountForTesting() -> Int { + state.withLock(\.overflowCount) + } + + private func beginNext() -> Bool { + state.withLock { state in + guard state.nextIsActive == false else { + return false + } + state.nextIsActive = true + return true + } + } + + private func endNext() { + state.withLock { state in + precondition(state.nextIsActive) + state.nextIsActive = false + } + } + + private static func accept( + _ event: CodexChatObservationEvent, + state: inout State + ) { + defer { + state.latestAcceptedCursor = (event.generation, event.sequence) + } + guard let latest = state.latestAcceptedCursor else { + guard case .snapshot = event.payload else { + preconditionFailure("The first observation event must be a snapshot.") + } + return + } + if event.generation > latest.generation { + guard event.sequence == 0, case .snapshot = event.payload else { + preconditionFailure("A new observation generation must start with sequence zero snapshot.") + } + return + } + precondition( + event.generation == latest.generation, + "Observation events cannot return to an older generation." + ) + switch event.payload { + case .snapshot: + precondition( + event.sequence >= latest.sequence, + "Observation snapshots cannot move their cursor backwards." + ) + case .update: + precondition( + event.sequence == latest.sequence &+ 1, + "Observation updates must advance the cursor by exactly one." + ) + } + } + + private static func compactPending( + with snapshot: CodexChatObservationEvent, + state: inout State + ) { + guard let first = state.pending.first else { + state.pending = [snapshot] + return + } + if snapshot.generation > first.generation { + state.pending = [snapshot] + return + } + precondition(snapshot.generation == first.generation) + state.pending.removeAll { + $0.generation < snapshot.generation + || ($0.generation == snapshot.generation + && $0.sequence <= snapshot.sequence) + } + state.pending.insert(snapshot, at: 0) + } + + private static func enqueue( + _ events: [CodexChatObservationEvent], + overflowSnapshot: CodexChatObservationEvent, + state: inout State + ) { + guard events.isEmpty == false else { return } + for event in events { + if case .snapshot = event.payload { + compactPending(with: event, state: &state) + } else if state.pending.count == capacity { + guard let finalEvent = events.last else { + preconditionFailure("A non-empty observation batch requires a final event.") + } + precondition( + overflowSnapshot.generation == finalEvent.generation + && overflowSnapshot.sequence == finalEvent.sequence + ) + guard case .snapshot(_, reason: .bufferOverflow) = overflowSnapshot.payload else { + preconditionFailure("Overflow compaction requires a buffer-overflow snapshot.") + } + state.pending = [overflowSnapshot] + state.overflowCount += 1 + return + } else { + state.pending.append(event) + } + } + } +} diff --git a/Sources/CodexDataKit/CodexChatUpdate.swift b/Sources/CodexDataKit/CodexChatUpdate.swift new file mode 100644 index 00000000..2a67390e --- /dev/null +++ b/Sources/CodexDataKit/CodexChatUpdate.swift @@ -0,0 +1,72 @@ +import CodexAppServerKit +import Foundation + +package enum CodexChatMutation: Equatable, Sendable { + case turnInserted(id: CodexTurnID) + case turnUpdated(id: CodexTurnID) + case turnRemoved(id: CodexTurnID) + case itemInserted(id: CodexChatItemID, turnID: CodexTurnID?) + case itemUpdated(id: CodexChatItemID, turnID: CodexTurnID?) + case itemRemoved(locator: CodexChatItemLocator, modelID: CodexChatItemID) + case itemTextAppended(id: CodexChatItemID, turnID: CodexTurnID?, delta: String) + case statusChanged(CodexThreadStatus?) + case phaseChanged(CodexChatPhase) + + var affectedTurnID: CodexTurnID? { + switch self { + case .statusChanged, .phaseChanged: + nil + case .turnInserted(let id), .turnUpdated(let id), .turnRemoved(let id): + id + case .itemInserted(_, let turnID), .itemUpdated(_, let turnID), + .itemTextAppended(_, let turnID, _): + turnID + case .itemRemoved(let locator, _): + locator.turnID + } + } +} + +public struct CodexChatItemLocator: Equatable, Hashable, Sendable { + public let id: String + public let kind: CodexThreadItem.Kind + public let turnID: CodexTurnID + + public init(id: String, kind: CodexThreadItem.Kind, turnID: CodexTurnID) { + self.id = id + self.kind = kind + self.turnID = turnID + } + + public init(item: CodexThreadItem, turnID: CodexTurnID) { + self.init(id: item.id, kind: item.kind, turnID: turnID) + } +} + +public enum CodexChatUpdate: Equatable, Sendable { + case turnInserted(CodexTurnSnapshot, index: Int) + case turnUpdated(CodexTurnSnapshot, index: Int) + case turnRemoved(id: CodexTurnID) + case itemInserted(item: CodexThreadItem, turnID: CodexTurnID, index: Int) + case itemUpdated(item: CodexThreadItem, turnID: CodexTurnID, index: Int) + case itemRemoved(CodexChatItemLocator) + case itemTextAppended(CodexChatItemLocator, delta: String) + case statusChanged(CodexThreadStatus?) + case phaseChanged(CodexChatPhase) + + public var affectedTurnID: CodexTurnID? { + switch self { + case .statusChanged, .phaseChanged: + nil + case .turnInserted(let turn, _), .turnUpdated(let turn, _): + turn.id + case .turnRemoved(let id): + id + case .itemInserted(_, let turnID, _), + .itemUpdated(_, let turnID, _): + turnID + case .itemRemoved(let locator), .itemTextAppended(let locator, _): + locator.turnID + } + } +} diff --git a/Sources/CodexDataKit/CodexDataPhase.swift b/Sources/CodexDataKit/CodexDataPhase.swift new file mode 100644 index 00000000..97a10166 --- /dev/null +++ b/Sources/CodexDataKit/CodexDataPhase.swift @@ -0,0 +1,43 @@ +import CodexAppServerKit + +public enum CodexTurnTerminalDisposition: Equatable, Sendable { + case completed + case interrupted + case failed + case invalid(rawStatus: String) +} + +public enum CodexChatPhase: Equatable, Sendable { + case idle + case loading + case running(turnID: CodexTurnID) + case terminal( + turnID: CodexTurnID, + disposition: CodexTurnTerminalDisposition + ) + case failed(CodexFetchFailure) + + public var turnID: CodexTurnID? { + switch self { + case .running(let turnID), .terminal(let turnID, _): + turnID + case .idle, .loading, .failed: + nil + } + } +} + +extension CodexTurnOutcome { + package var chatTerminalDisposition: CodexTurnTerminalDisposition { + switch self { + case .completed: + .completed + case .interrupted: + .interrupted + case .failed: + .failed + case .invalidTerminalStatus(let rawStatus, _, _): + .invalid(rawStatus: rawStatus) + } + } +} diff --git a/Sources/CodexDataKit/CodexFetchRequest.swift b/Sources/CodexDataKit/CodexFetchRequest.swift new file mode 100644 index 00000000..29f3cbaa --- /dev/null +++ b/Sources/CodexDataKit/CodexFetchRequest.swift @@ -0,0 +1,1647 @@ +import CodexAppServerKit +import Foundation +import Observation + +package enum CodexSortKey: Sendable, Hashable { + case name + case createdAt + case updatedAt + case recencyAt +} + +package enum CodexSortPath: Sendable, Hashable { + case workspaceGroupName + case workspaceName + case chatTitle + case chatName + case chatCreatedAt + case chatUpdatedAt + case chatRecencyAt + + package var sortKey: CodexSortKey { + switch self { + case .workspaceGroupName, .workspaceName, .chatTitle, .chatName: + return .name + case .chatCreatedAt: + return .createdAt + case .chatUpdatedAt: + return .updatedAt + case .chatRecencyAt: + return .recencyAt + } + } +} + +public enum CodexFetchValidationError: Error, Hashable, LocalizedError, Sendable { + case unsupportedModel(String) + case unsupportedPredicate(String) + case unsupportedSort(String) + case unsupportedSection(String) + case invalidArchiveScope(String) + case negativeFetchLimit(Int) + case negativeFetchOffset(Int) + + public var errorDescription: String? { + switch self { + case .unsupportedModel(let model): + "CodexDataKit does not support fetching \(model)." + case .unsupportedPredicate(let predicate): + "CodexDataKit does not support predicate \(predicate)." + case .unsupportedSort(let sort): + "CodexDataKit does not support sort descriptor \(sort)." + case .unsupportedSection(let section): + "CodexDataKit does not support section descriptor \(section)." + case .invalidArchiveScope(let scope): + "CodexDataKit cannot represent archive scope \(scope)." + case .negativeFetchLimit(let limit): + "CodexDataKit fetchLimit must be non-negative; received \(limit)." + case .negativeFetchOffset(let offset): + "CodexDataKit fetchOffset must be non-negative; received \(offset)." + } + } +} + +public enum CodexFetchFailure: Error, Equatable, LocalizedError, Sendable { + case validation(CodexFetchValidationError) + case appServer(CodexAppServerError) + + public var errorDescription: String? { + switch self { + case .validation(let error): + error.localizedDescription + case .appServer(let error): + error.localizedDescription + } + } +} + +public enum CodexFetchPhase: Equatable, Sendable { + case idle + case loading + case loaded + case failed(CodexFetchFailure) +} + +public struct CodexSortDescriptor: Hashable, @unchecked Sendable { + package let keyPath: PartialKeyPath + package let stringComparator: String.StandardComparator? + public let order: SortOrder + + public init( + _ keyPath: any KeyPath & Sendable, + order: SortOrder = .forward + ) { + self.keyPath = keyPath + self.stringComparator = nil + self.order = order + } + + public init( + _ keyPath: any KeyPath & Sendable, + order: SortOrder = .forward + ) { + self.keyPath = keyPath + self.stringComparator = nil + self.order = order + } + + public init( + _ keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward + ) { + self.keyPath = keyPath + self.stringComparator = comparator + self.order = order + } + + public init( + _ keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward + ) { + self.keyPath = keyPath + self.stringComparator = comparator + self.order = order + } +} + +package struct CodexSortPlan: Sendable, Hashable { + package var path: CodexSortPath + package var key: CodexSortKey { + path.sortKey + } + package var order: SortOrder + package var stringComparator: String.StandardComparator? + + package init(descriptor: CodexSortDescriptor) throws { + guard let path = CodexKnownKeyPaths.sortPath( + for: Model.self, + keyPath: descriptor.keyPath + ) + else { + throw CodexFetchValidationError.unsupportedSort( + "\(Model.self).\(descriptor.keyPath)" + ) + } + self.path = path + self.order = descriptor.order + self.stringComparator = descriptor.stringComparator + } + + package static func afterValidation( + _ descriptor: CodexSortDescriptor + ) -> Self { + do { + return try Self(descriptor: descriptor) + } catch { + preconditionFailure( + "CodexSortDescriptor was used before successful validation: \(error)" + ) + } + } + + package var threadSortDirection: CodexSortDirection { + switch order { + case .forward: + .ascending + case .reverse: + .descending + } + } + + package var threadSortKey: CodexThreadSortKey? { + switch key { + case .createdAt: + return .createdAt + case .updatedAt: + return .updatedAt + case .recencyAt: + return .recencyAt + case .name: + return nil + } + } + + package func compare(_ lhs: Model, _ rhs: Model) -> ComparisonResult { + let result: ComparisonResult + switch path { + case .workspaceGroupName: + result = compareStrings( + (lhs as! CodexWorkspaceGroup).name, + (rhs as! CodexWorkspaceGroup).name + ) + case .workspaceName: + result = compareStrings( + (lhs as! CodexWorkspace).name, + (rhs as! CodexWorkspace).name + ) + case .chatTitle: + result = compareStrings( + (lhs as! CodexChat).title, + (rhs as! CodexChat).title + ) + case .chatName: + result = compareOptional( + (lhs as! CodexChat).name, + (rhs as! CodexChat).name, + compare: compareStrings + ) + case .chatCreatedAt: + result = compareOptional( + (lhs as! CodexChat).createdAt, + (rhs as! CodexChat).createdAt, + compare: compareComparable + ) + case .chatUpdatedAt: + result = compareOptional( + (lhs as! CodexChat).updatedAt, + (rhs as! CodexChat).updatedAt, + compare: compareComparable + ) + case .chatRecencyAt: + result = compareOptional( + (lhs as! CodexChat).recencyAt, + (rhs as! CodexChat).recencyAt, + compare: compareComparable + ) + } + return order == .forward ? result : result.reversed + } + + private func compareStrings(_ lhs: String, _ rhs: String) -> ComparisonResult { + (stringComparator ?? .localizedStandard).compare(lhs, rhs) + } + + private func compareComparable( + _ lhs: Value, + _ rhs: Value + ) -> ComparisonResult { + if lhs < rhs { + return .orderedAscending + } + if rhs < lhs { + return .orderedDescending + } + return .orderedSame + } + + private func compareOptional( + _ lhs: Value?, + _ rhs: Value?, + compare: (Value, Value) -> ComparisonResult + ) -> ComparisonResult { + switch (lhs, rhs) { + case (.none, .none): + .orderedSame + case (.none, .some): + .orderedAscending + case (.some, .none): + .orderedDescending + case (.some(let lhs), .some(let rhs)): + compare(lhs, rhs) + } + } + +} + +private extension ComparisonResult { + var reversed: ComparisonResult { + switch self { + case .orderedAscending: + .orderedDescending + case .orderedSame: + .orderedSame + case .orderedDescending: + .orderedAscending + } + } +} + +package enum CodexSectionKey: Sendable, Hashable { + case workspaceGroup + case workspace +} + +public struct CodexSectionDescriptor: Hashable, @unchecked Sendable { + package let keyPath: PartialKeyPath + + public init( + _ keyPath: any KeyPath & Sendable + ) { + self.keyPath = keyPath + } + + public init( + _ keyPath: any KeyPath & Sendable + ) { + self.keyPath = keyPath + } + + package func resolveKey() throws -> CodexSectionKey { + guard let key = CodexKnownKeyPaths.sectionKey( + for: Model.self, + keyPath: keyPath + ) else { + throw CodexFetchValidationError.unsupportedSection( + "\(Model.self).\(keyPath)" + ) + } + return key + } +} + +extension CodexSectionDescriptor where Model == CodexWorkspace { + public static var workspaceGroup: Self { + .init(\CodexWorkspace.workspaceGroupID) + } +} + +extension CodexSectionDescriptor where Model == CodexChat { + public static var workspaceGroup: Self { + .init(\CodexChat.workspaceGroupID) + } + + public static var workspace: Self { + .init(\CodexChat.workspaceID) + } +} + +public struct CodexFetchDescriptor: Equatable, Sendable { + public var predicate: Predicate? + public var sortBy: [CodexSortDescriptor] + public var fetchLimit: Int? + public var fetchOffset: Int? + public var includeContextChanges: Bool + + public init( + predicate: Predicate? = nil, + sortBy: [CodexSortDescriptor] = [], + fetchLimit: Int? = nil, + fetchOffset: Int? = nil, + includeContextChanges: Bool = true + ) { + self.predicate = predicate + self.sortBy = sortBy + self.fetchLimit = fetchLimit + self.fetchOffset = fetchOffset + self.includeContextChanges = includeContextChanges + } + + public static func == ( + lhs: CodexFetchDescriptor, + rhs: CodexFetchDescriptor + ) -> Bool { + lhs.querySignature == rhs.querySignature + } + + package var normalizedFetchOffset: Int { + fetchOffset ?? 0 + } + + package func validatedSortPlans() throws -> [CodexSortPlan] { + try sortBy.map(CodexSortPlan.init(descriptor:)) + } + + package func validate( + sectionBy: CodexSectionDescriptor? = nil + ) throws { + if let fetchLimit, fetchLimit < 0 { + throw CodexFetchValidationError.negativeFetchLimit(fetchLimit) + } + if let fetchOffset, fetchOffset < 0 { + throw CodexFetchValidationError.negativeFetchOffset(fetchOffset) + } + if let failure = querySignature.validationFailure { + throw failure + } + _ = try sectionBy?.resolveKey() + } +} + +package enum CodexKnownKeyPaths { + static func sortPath( + for _: Model.Type, + keyPath: AnyKeyPath + ) -> CodexSortPath? { + if Model.self == CodexWorkspaceGroup.self { + return sortPathForWorkspaceGroup(keyPath) + } + if Model.self == CodexWorkspace.self { + return sortPathForWorkspace(keyPath) + } + if Model.self == CodexChat.self { + return sortPathForChat(keyPath) + } + return nil + } + + static func sortKey( + for _: Model.Type, + keyPath: AnyKeyPath + ) -> CodexSortKey? { + sortPath(for: Model.self, keyPath: keyPath)?.sortKey + } + + static func sectionKey( + for _: Model.Type, + keyPath: AnyKeyPath + ) -> CodexSectionKey? { + if Model.self == CodexWorkspace.self { + return keyPath == (\CodexWorkspace.workspaceGroupID as AnyKeyPath) + ? .workspaceGroup + : nil + } + if Model.self == CodexChat.self { + if keyPath == (\CodexChat.workspaceGroupID as AnyKeyPath) { + return .workspaceGroup + } + if keyPath == (\CodexChat.workspaceID as AnyKeyPath) { + return .workspace + } + } + return nil + } + + private static func sortPathForWorkspaceGroup(_ keyPath: AnyKeyPath) -> CodexSortPath? { + keyPath == (\CodexWorkspaceGroup.name as AnyKeyPath) ? .workspaceGroupName : nil + } + + private static func sortPathForWorkspace(_ keyPath: AnyKeyPath) -> CodexSortPath? { + keyPath == (\CodexWorkspace.name as AnyKeyPath) ? .workspaceName : nil + } + + private static func sortPathForChat(_ keyPath: AnyKeyPath) -> CodexSortPath? { + if keyPath == (\CodexChat.title as AnyKeyPath) { + return .chatTitle + } + if keyPath == (\CodexChat.name as AnyKeyPath) { + return .chatName + } + if keyPath == (\CodexChat.createdAt as AnyKeyPath) { + return .chatCreatedAt + } + if keyPath == (\CodexChat.updatedAt as AnyKeyPath) { + return .chatUpdatedAt + } + if keyPath == (\CodexChat.recencyAt as AnyKeyPath) { + return .chatRecencyAt + } + return nil + } +} + +extension CodexFetchDescriptor where Model == CodexWorkspaceGroup { + public static var workspaceGroups: Self { + .init(sortBy: codexDefaultWorkspaceGroupSortDescriptors()) + } +} + +extension CodexFetchDescriptor where Model == CodexWorkspace { + public static var workspaces: Self { + .init(sortBy: codexDefaultWorkspaceSortDescriptors()) + } + + public static func workspaces( + sortBy: [CodexSortDescriptor] = codexDefaultWorkspaceSortDescriptors() + ) -> Self { + .init(sortBy: sortBy) + } +} + +extension CodexFetchDescriptor where Model == CodexChat { + public static var recentChats: Self { + .init( + predicate: #Predicate { $0.isArchived == false }, + sortBy: codexDefaultChatSortDescriptors() + ) + } + + public static func chats( + in workspace: CodexWorkspace, + fetchLimit: Int? = nil + ) -> Self { + chats(in: workspace, sortBy: codexDefaultChatSortDescriptors(), fetchLimit: fetchLimit) + } + + public static func chats( + in workspace: CodexWorkspace, + sortBy: [CodexSortDescriptor], + fetchLimit: Int? = nil + ) -> Self { + let scopedWorkspaceID: CodexWorkspaceID? = workspace.id + return .init( + predicate: #Predicate { chat in + chat.workspaceID == scopedWorkspaceID && chat.isArchived == false + }, + sortBy: sortBy, + fetchLimit: fetchLimit + ) + } +} + +@usableFromInline +func codexDefaultWorkspaceGroupSortDescriptors() + -> [CodexSortDescriptor] +{ + [CodexSortDescriptor(\.name)] +} + +@usableFromInline +func codexDefaultWorkspaceSortDescriptors() -> [CodexSortDescriptor] { + [CodexSortDescriptor(\.name)] +} + +@usableFromInline +func codexDefaultChatSortDescriptors() -> [CodexSortDescriptor] { + [CodexSortDescriptor(\.updatedAt, order: .reverse)] +} + +public enum CodexFetchSectionID: Sendable, Hashable, CustomStringConvertible { + case `default` + case workspaceGroup(CodexWorkspaceGroupID) + case workspace(CodexWorkspaceID) + case unknown(String) + + public var description: String { + switch self { + case .default: + "default" + case .workspaceGroup(let id): + id.rawValue + case .workspace(let id): + id.rawValue + case .unknown(let rawValue): + rawValue + } + } +} + +public struct CodexFetchSection: Identifiable { + public var id: CodexFetchSectionID + public var title: String? + public var items: [Model] + + public init(id: CodexFetchSectionID, title: String?, items: [Model]) { + self.id = id + self.title = title + self.items = items + } +} + +extension CodexFetchSection where Model == CodexChat { + public var workspaceGroupID: CodexWorkspaceGroupID? { + guard case .workspaceGroup(let id) = id else { + return nil + } + return id + } + + public var workspaceID: CodexWorkspaceID? { + guard case .workspace(let id) = id else { + return nil + } + return id + } + + public var workspaceGroup: CodexWorkspaceGroup? { + codexOnlyWorkspaceGroup(items.map { $0.workspace?.workspaceGroup }) + } + + public var workspaces: [CodexWorkspace] { + codexUniqueWorkspaces(items.compactMap(\.workspace)) + } + + public var uncategorizedChats: [CodexChat] { + items.filter { $0.workspace == nil } + } + + public func chats(in workspaceID: CodexWorkspaceID) -> [CodexChat] { + items.filter { $0.workspace?.id == workspaceID } + } + + public func chat(id: CodexThreadID) -> CodexChat? { + items.first { $0.id == id } + } +} + +extension CodexFetchSection where Model == CodexWorkspace { + public var workspaceGroupID: CodexWorkspaceGroupID? { + guard case .workspaceGroup(let id) = id else { + return nil + } + return id + } + + public var workspaceGroup: CodexWorkspaceGroup? { + codexOnlyWorkspaceGroup(items.map(\.workspaceGroup)) + } + + public var workspaces: [CodexWorkspace] { + codexUniqueWorkspaces(items) + } +} + +private func codexUniqueWorkspaces(_ workspaces: [CodexWorkspace]) -> [CodexWorkspace] { + var seen: Set = [] + var result: [CodexWorkspace] = [] + for workspace in workspaces where seen.insert(workspace.id).inserted { + result.append(workspace) + } + return result +} + +private func codexOnlyWorkspaceGroup(_ groups: [CodexWorkspaceGroup?]) -> CodexWorkspaceGroup? { + var result: CodexWorkspaceGroup? + var hasMissingGroup = false + for group in groups { + guard let group else { + hasMissingGroup = true + continue + } + guard let existing = result else { + result = group + continue + } + guard existing.id == group.id else { + return nil + } + } + return hasMissingGroup && result != nil ? nil : result +} + +package struct CodexFetchPage { + package var items: [Model] + package var nextCursor: String? + package var backwardsCursor: String? + package var relationshipItems: [Model]? = nil + package var relationshipIsComplete: Bool? = nil +} + +package struct CodexFetchedChatRevalidation { + package var chat: CodexChat + package var previousWorkspace: CodexWorkspace? + package var previousGroup: CodexWorkspaceGroup? + package var archived: Bool +} + +package protocol CodexFetchedResultsRegistration: AnyObject { + func insert(_ chat: CodexChat, archived: Bool) async + func archive( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) async + func revalidate(_ changes: [CodexFetchedChatRevalidation]) async + func remove( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) async + func refresh(_ workspace: CodexWorkspace, archived: Bool, removedChats: [CodexChat]) async + func refresh(_ group: CodexWorkspaceGroup, archived: Bool, removedChats: [CodexChat]) async +} + +@Observable +public final class CodexFetchedResults { + public let modelContext: CodexModelContext + public private(set) var fetchDescriptor: CodexFetchDescriptor + package let querySignature: CodexFetchDescriptorSignature + public private(set) var sectionBy: CodexSectionDescriptor? + public private(set) var items: [Model] = [] + public private(set) var sections: [CodexFetchSection] = [] + public private(set) var nextCursor: String? + public private(set) var backwardsCursor: String? + public private(set) var phase: CodexFetchPhase + + @ObservationIgnored + private let validationFailure: CodexFetchValidationError? + + @ObservationIgnored + private var hasPerformedFetch = false + + @ObservationIgnored + private let loadCoordinator = FetchedResultsLoadCoordinator() + + @ObservationIgnored + private let transactionRelay = CodexAsyncStreamRelay>( + bufferingPolicy: .bufferingNewest(1) + ) + + package init( + modelContext: CodexModelContext, + fetchDescriptor: CodexFetchDescriptor, + sectionBy: CodexSectionDescriptor? + ) { + self.modelContext = modelContext + self.fetchDescriptor = fetchDescriptor + self.querySignature = fetchDescriptor.querySignature + self.sectionBy = sectionBy + do { + try fetchDescriptor.validate(sectionBy: sectionBy) + self.validationFailure = nil + self.phase = .idle + } catch let failure as CodexFetchValidationError { + self.validationFailure = failure + self.phase = .failed(.validation(failure)) + } catch { + preconditionFailure("Unexpected fetch descriptor validation error: \(error)") + } + } + + deinit { + transactionRelay.finish() + } + + public var transactions: AsyncStream> { + transactionRelay.makeStream() + } + + public var snapshot: CodexFetchedResultsSnapshot { + CodexFetchedResultsSnapshot(sections: sections) + } + + package func waitUntilPendingLoad() async { + await loadCoordinator.waitUntilPendingLoad() + } + + public nonisolated(nonsending) func performFetch() async throws { + try await loadCoordinator.withPermit { + let reason: CodexFetchedResultsTransactionReason = + hasPerformedFetch ? .refresh : .initialFetch + try await executeLoad( + fetchDescriptor, + appending: false, + reason: reason + ) + } + } + + public nonisolated(nonsending) func refresh() async throws { + try await performFetch() + } + + public nonisolated(nonsending) func loadNextPage() async throws { + try await loadCoordinator.withPermit { + guard let nextCursor else { + return + } + try await executeLoad( + fetchDescriptor, + cursor: nextCursor, + appending: true, + reason: .pageAppend + ) + } + } + + private func load( + _ descriptor: CodexFetchDescriptor, + cursor: String? = nil, + appending: Bool, + reason: CodexFetchedResultsTransactionReason, + targetWindowCount: Int? = nil + ) async throws { + try await loadCoordinator.withPermit { + try await executeLoad( + descriptor, + cursor: cursor, + appending: appending, + reason: reason, + targetWindowCount: targetWindowCount + ) + } + } + + private func executeLoad( + _ descriptor: CodexFetchDescriptor, + cursor: String? = nil, + appending: Bool, + reason: CodexFetchedResultsTransactionReason, + targetWindowCount: Int? = nil + ) async throws { + let stablePhase = phase + phase = .loading + let previousBackwardsCursor = backwardsCursor + do { + try descriptor.validate(sectionBy: sectionBy) + let page = try await stagedPage( + descriptor, + cursor: cursor, + appending: appending, + targetWindowCount: targetWindowCount + ) + try Task.checkCancellation() + let newItems = loadedItems( + from: page, + appending: appending, + cursor: cursor + ) + let relationshipDescriptor = appending ? fetchDescriptor : descriptor + await modelContext.syncLoadedRelationships( + from: page, + descriptor: relationshipDescriptor, + loadedItems: newItems, + cursor: cursor, + excluding: self + ) + try Task.checkCancellation() + nextCursor = page.nextCursor + backwardsCursor = appending ? previousBackwardsCursor : page.backwardsCursor + phase = .loaded + hasPerformedFetch = true + updateItemsAndSections( + items: newItems, + sections: modelContext.sections(for: newItems, sectionBy: sectionBy), + reason: reason + ) + } catch is CancellationError { + phase = stablePhase + throw CancellationError() + } catch let validation as CodexFetchValidationError { + let failure = CodexFetchFailure.validation(validation) + phase = .failed(failure) + throw failure + } catch let failure as CodexFetchFailure { + phase = .failed(failure) + throw failure + } catch let appServer as CodexAppServerError { + let failure = CodexFetchFailure.appServer(appServer) + phase = .failed(failure) + throw failure + } catch { + preconditionFailure("Unexpected CodexDataKit fetch error: \(error)") + } + } + + private func stagedPage( + _ descriptor: CodexFetchDescriptor, + cursor: String?, + appending: Bool, + targetWindowCount: Int? + ) async throws -> CodexFetchPage { + var page = try await modelContext.fetchPage( + descriptor, + cursor: cursor, + excluding: self + ) + guard appending == false, hasPerformedFetch else { + return page + } + + let targetCount = targetWindowCount ?? items.count + var stagedItems = page.items + var stagedIDs = Set(stagedItems.map(\.id)) + var nextCursor = page.nextCursor + let firstBackwardsCursor = page.backwardsCursor + var relationshipItems = page.relationshipItems + var relationshipIsComplete = page.relationshipIsComplete + + while stagedItems.count < targetCount, let cursor = nextCursor { + try Task.checkCancellation() + let nextPage = try await modelContext.fetchPage( + descriptor, + cursor: cursor, + excluding: self + ) + for item in nextPage.items where stagedIDs.insert(item.id).inserted { + stagedItems.append(item) + } + nextCursor = nextPage.nextCursor + if let nextRelationshipItems = nextPage.relationshipItems { + relationshipItems = nextRelationshipItems + } + if let nextRelationshipIsComplete = nextPage.relationshipIsComplete { + relationshipIsComplete = nextRelationshipIsComplete + } + } + + page = CodexFetchPage( + items: stagedItems, + nextCursor: nextCursor, + backwardsCursor: firstBackwardsCursor, + relationshipItems: relationshipItems, + relationshipIsComplete: relationshipIsComplete + ) + return page + } + + private func loadedItems( + from page: CodexFetchPage, + appending: Bool, + cursor: String? + ) -> [Model] { + guard appending else { + return replacingItems(from: page) + } + if page.relationshipIsComplete == true, let authoritativeItems = page.relationshipItems { + let start = min( + fetchDescriptor.normalizedFetchOffset, + authoritativeItems.count + ) + let end = min(start + items.count + page.items.count, authoritativeItems.count) + let windowPage = CodexFetchPage( + items: Array(authoritativeItems[start.. 0 else { + return appendedItems + } + return modelContext.sortedItems(appendedItems, for: fetchDescriptor) + } + + private func replacingItems(from page: CodexFetchPage) -> [Model] { + modelContext.fetchedItemsIncludingPendingChanges( + from: page, + descriptor: fetchDescriptor, + existingItems: items + ) + } + + private func append(_ incoming: [Model], to existing: [Model]) -> [Model] { + var result = existing + for item in incoming { + if let index = result.firstIndex(where: { $0.id == item.id }) { + result[index] = item + } else { + result.append(item) + } + } + return result + } + + private func updateItemsAndSections( + items newItems: [Model], + sections newSections: [CodexFetchSection], + reason: CodexFetchedResultsTransactionReason, + updatedItemIDs: Set = [] + ) { + let oldSnapshot = snapshot + items = newItems + sections = newSections + yieldTransaction( + reason: reason, + oldSnapshot: oldSnapshot, + updatedItemIDs: updatedItemIDs + ) + } + + private func yieldTransaction( + reason: CodexFetchedResultsTransactionReason, + oldSnapshot: CodexFetchedResultsSnapshot, + updatedItemIDs: Set + ) { + guard transactionRelay.hasContinuations else { + return + } + let transaction = CodexFetchedResultsTransaction( + reason: reason, + oldSnapshot: oldSnapshot, + newSnapshot: snapshot, + updatedItemIDs: updatedItemIDs + ) + guard transaction.hasChanges + || reason == .initialFetch + || reason == .refresh + else { + return + } + transactionRelay.yield(transaction) + } +} + +extension CodexFetchedResults: CodexFetchedResultsRegistration { + package func insert(_ chat: CodexChat, archived: Bool) async { + guard validationFailure == nil else { + return + } + if mutationStrategy(for: .insert) == .refreshLoadedWindow { + if let model = insertionModel(for: chat, archived: archived) { + _ = upsert(model, reason: .insert) + } + await refreshAfterMutation(reason: .insert) + return + } + guard let model = insertionModel(for: chat, archived: archived) else { + return + } + await upsertOrRefresh(model, reason: .insert) + } + + package func archive( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) async { + guard validationFailure == nil else { + return + } + if mutationStrategy(for: .archive) == .refreshLoadedWindow { + let targetWindowCount = items.count + _ = applyLocalRevalidation([ + CodexFetchedChatRevalidation( + chat: chat, + previousWorkspace: workspace, + previousGroup: group, + archived: true + ) + ], reason: .archive) + await refreshAfterMutation( + reason: .archive, + targetWindowCount: targetWindowCount + ) + return + } + if let model = insertionModel(for: chat, archived: true) { + await upsertOrRefresh(model, reason: .archive) + } else { + await remove(chat, workspace: workspace, group: group, reason: .archive) + } + } + + package func revalidate(_ changes: [CodexFetchedChatRevalidation]) async { + guard validationFailure == nil else { + return + } + guard changes.isEmpty == false else { + return + } + let affectsMembership = changes.contains { + shouldInclude($0.chat, archived: $0.archived) + } + if mutationStrategy(for: .revalidate( + affectsMembership: affectsMembership, + hasNextPage: nextCursor != nil + )) == .refreshLoadedWindow { + let targetWindowCount = items.count + _ = applyLocalRevalidation(changes, reason: .revalidate) + await refreshAfterMutation( + reason: .revalidate, + targetWindowCount: targetWindowCount + ) + return + } + let originalCount = applyLocalRevalidation(changes, reason: .revalidate) + if canEvaluateFilterLocally { + for change in changes { + guard let model = insertionModel(for: change.chat, archived: change.archived) else { + continue + } + guard await upsertOrRefresh(model, reason: .revalidate) else { + return + } + } + } + if items.count < originalCount, + mutationStrategy(for: .remove(hasNextPage: nextCursor != nil)) + == .refreshLoadedWindow + { + await refreshAfterMutation( + reason: .revalidate, + targetWindowCount: originalCount + ) + } + } + + package func remove( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) async { + guard validationFailure == nil else { + return + } + await remove(chat, workspace: workspace, group: group, reason: .remove) + } + + private func remove( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup?, + reason: CodexFetchedResultsTransactionReason + ) async { + guard validationFailure == nil else { + return + } + let originalCount = applyLocalRemoval( + of: chat, + workspace: workspace, + group: group, + reason: reason + ) + if mutationStrategy(for: .remove(hasNextPage: nextCursor != nil)) + == .refreshLoadedWindow + { + await refreshAfterMutation( + reason: reason, + targetWindowCount: originalCount + ) + return + } + guard items.count != originalCount else { + return + } + } + + package func refresh( + _ workspace: CodexWorkspace, + archived: Bool, + removedChats: [CodexChat] + ) async { + guard validationFailure == nil else { + return + } + let originalCount = items.count + let refreshed = refreshItems(archived: archived, keeping: { + shouldKeep($0, afterRefreshing: workspace, removedChats: removedChats) + }, reason: .refresh) + if mutationStrategy(for: .relationshipRefresh) == .refreshLoadedWindow { + await refreshAfterMutation( + reason: .refresh, + targetWindowCount: originalCount + ) + return + } + guard refreshed else { + return + } + guard upsertLoadedModels(from: workspace) else { + await refreshAfterMutation(reason: .refresh) + return + } + if items.count < originalCount, + mutationStrategy(for: .remove(hasNextPage: nextCursor != nil)) + == .refreshLoadedWindow + { + await refreshAfterMutation( + reason: .refresh, + targetWindowCount: originalCount + ) + } + } + + package func refresh( + _ group: CodexWorkspaceGroup, + archived: Bool, + removedChats: [CodexChat] + ) async { + guard validationFailure == nil else { + return + } + let originalCount = items.count + let refreshed = refreshItems(archived: archived, keeping: { + shouldKeep($0, afterRefreshing: group, removedChats: removedChats) + }, reason: .refresh) + if mutationStrategy(for: .relationshipRefresh) == .refreshLoadedWindow { + await refreshAfterMutation( + reason: .refresh, + targetWindowCount: originalCount + ) + return + } + guard refreshed else { + return + } + guard upsertLoadedModels(from: group) else { + await refreshAfterMutation(reason: .refresh) + return + } + if items.count < originalCount, + mutationStrategy(for: .remove(hasNextPage: nextCursor != nil)) + == .refreshLoadedWindow + { + await refreshAfterMutation( + reason: .refresh, + targetWindowCount: originalCount + ) + } + } + + private func insertionModel(for chat: CodexChat, archived: Bool) -> Model? { + guard canEvaluateFilterLocally else { + return nil + } + guard shouldInclude(chat, archived: archived) else { + return nil + } + if archived { + restoreArchivedRelationships(for: chat) + } + if let chatModel = chat as? Model { + return chatModel + } + if let workspace = chat.workspace as? Model { + return workspace + } + if let workspace = chat.workspace, + let workspaceGroup = workspace.workspaceGroup, + let group = workspaceGroup as? Model + { + if workspaceGroup.workspaces.contains(where: { $0 === workspace }) == false { + workspaceGroup.replaceContextWorkspaces(workspaceGroup.workspaces + [workspace]) + } + return group + } + return nil + } + + @discardableResult + private func upsertOrRefresh( + _ model: Model, + reason: CodexFetchedResultsTransactionReason + ) async -> Bool { + guard upsert(model, reason: reason) else { + await refreshAfterMutation(reason: reason) + return false + } + return true + } + + @discardableResult + private func upsert( + _ model: Model, + reason: CodexFetchedResultsTransactionReason + ) -> Bool { + var nextItems = items + let insertedModel: Bool + if let index = nextItems.firstIndex(where: { $0.id == model.id }) { + nextItems[index] = model + insertedModel = false + } else { + guard canInsertLiveModel else { + return false + } + nextItems.insert(model, at: 0) + insertedModel = true + } + let sortedItems = modelContext.sortedItems(nextItems, for: fetchDescriptor) + let windowItems = loadedWindowItems( + sortedItems, + insertedModel: insertedModel + ) + if insertedModel, + nextCursor == nil, + sortedItems.count > windowItems.count + { + let cursorOffset = fetchDescriptor.normalizedFetchOffset + windowItems.count + nextCursor = modelContext.localCursor(for: cursorOffset) + } + updateItemsAndSections( + items: windowItems, + sections: modelContext.sections(for: windowItems, sectionBy: sectionBy), + reason: reason, + updatedItemIDs: insertedModel ? [] : [model.id] + ) + return true + } + + private var canInsertLiveModel: Bool { + canEvaluateFilterLocally + && fetchDescriptor.includeContextChanges + && fetchDescriptor.normalizedFetchOffset == 0 + && (nextCursor == nil || fetchDescriptor.fetchLimit == nil) + } + + private func loadedWindowItems(_ models: [Model], insertedModel: Bool) -> [Model] { + guard let fetchLimit = fetchDescriptor.fetchLimit else { + return models + } + let loadedCount = items.count + let targetCount = insertedModel + && loadedCount < fetchLimit + ? loadedCount + 1 + : loadedCount + return Array(models.prefix(max(targetCount, 0))) + } + + private var canEvaluateFilterLocally: Bool { + membershipRequiresServerRefresh == false + } + + private var membershipRequiresServerRefresh: Bool { + chatQueryPlan?.membershipRequiresServerRefresh ?? false + } + + private func mutationStrategy( + for operation: CodexFetchedResultsMutationOperation + ) -> CodexFetchedResultsMutationStrategy { + if let chatQueryPlan { + return chatQueryPlan.mutationStrategy(for: operation) + } + switch operation { + case .remove(let hasNextPage): + return fetchDescriptor.normalizedFetchOffset > 0 || hasNextPage + ? .refreshLoadedWindow + : .removeLocally + case .revalidate(let affectsMembership, let hasNextPage): + return affectsMembership + && (fetchDescriptor.normalizedFetchOffset > 0 || hasNextPage) + ? .refreshLoadedWindow + : .applyLocally + case .insert, .archive, .relationshipRefresh: + return .applyLocally + } + } + + private var chatQueryPlan: CodexThreadQueryPlan? { + guard Model.self == CodexChat.self else { + return nil + } + guard querySignature.validationFailure == nil else { + return nil + } + return try? CodexThreadQueryPlan( + descriptor: fetchDescriptor as! CodexFetchDescriptor + ) + } + + private func refreshAfterMutation( + reason: CodexFetchedResultsTransactionReason, + targetWindowCount: Int? = nil + ) async { + do { + try await load( + fetchDescriptor, + appending: false, + reason: reason, + targetWindowCount: targetWindowCount + ) + } catch { + // performFetch records the failed phase; the server mutation has already succeeded. + } + } + + private func shouldInclude(_ chat: CodexChat, archived: Bool) -> Bool { + guard let chatQueryPlan else { + return archived == false + } + var record = CodexChatRecord(chat: chat) + record.isArchived = archived + return chatQueryPlan.matchesLocalCandidate(record) + } + + private func shouldKeep( + _ item: Model, + afterRemoving chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) -> Bool { + if let item = item as? CodexChat { + return item.id != chat.id + } + if let item = item as? CodexWorkspace, let workspace { + guard canEvaluateFilterLocally else { + return true + } + return item.id != workspace.id || containsIncludedChat(in: item) + } + if let item = item as? CodexWorkspaceGroup, let group { + guard canEvaluateFilterLocally else { + return true + } + return item.id != group.id || containsIncludedWorkspace(in: item) + } + return true + } + + private func shouldKeep( + _ item: Model, + afterRevalidating chat: CodexChat, + previousWorkspace: CodexWorkspace?, + previousGroup: CodexWorkspaceGroup?, + archived: Bool + ) -> Bool { + if let item = item as? CodexChat, item.id == chat.id { + return shouldInclude(chat, archived: archived) + } + if let item = item as? CodexWorkspace, + item.id == previousWorkspace?.id || item.id == chat.workspace?.id + { + return containsIncludedChat(in: item) + } + if let item = item as? CodexWorkspaceGroup, + item.id == previousGroup?.id || item.id == chat.workspace?.workspaceGroup?.id + { + return containsIncludedWorkspace(in: item) + } + return true + } + + private func shouldKeep( + _ item: Model, + afterRefreshing workspace: CodexWorkspace, + removedChats: [CodexChat] + ) -> Bool { + if let item = item as? CodexChat { + if removedChats.contains(where: { $0 === item }) { + return false + } + if workspace.chats.contains(where: { $0 === item }) { + return shouldInclude(item, archived: item.isArchived) + } + if requestIsScoped(to: workspace) { + return false + } + return true + } + if let item = item as? CodexWorkspace, + item.id == workspace.id + { + guard canEvaluateFilterLocally else { + return true + } + return containsIncludedChat(in: item) + } + if let item = item as? CodexWorkspaceGroup, + let group = workspace.workspaceGroup, + item.id == group.id + { + guard canEvaluateFilterLocally else { + return true + } + return containsIncludedWorkspace(in: item) + } + return true + } + + private func shouldKeep( + _ item: Model, + afterRefreshing group: CodexWorkspaceGroup, + removedChats: [CodexChat] + ) -> Bool { + if let item = item as? CodexChat { + if removedChats.contains(where: { $0 === item }) { + return false + } + guard canEvaluateFilterLocally else { + return true + } + guard let workspace = item.workspace, + group.workspaces.contains(where: { $0 === workspace }) + else { + return true + } + return workspace.chats.contains { $0 === item } + && shouldInclude(item, archived: item.isArchived) + } + if let item = item as? CodexWorkspace, + item.workspaceGroup?.id == group.id + { + guard canEvaluateFilterLocally else { + return true + } + return group.workspaces.contains { $0 === item } && containsIncludedChat(in: item) + } + if let item = item as? CodexWorkspaceGroup, + item.id == group.id + { + guard canEvaluateFilterLocally else { + return true + } + return containsIncludedWorkspace(in: item) + } + return true + } + + @discardableResult + private func applyLocalRemoval( + of chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup?, + reason: CodexFetchedResultsTransactionReason + ) -> Int { + let originalCount = items.count + let filteredItems = items.filter { + shouldKeep($0, afterRemoving: chat, workspace: workspace, group: group) + } + let updatedItemIDs = updatedItemIDsAfterRemoval( + of: chat, + workspace: workspace, + group: group + ) + if filteredItems.count != items.count || updatedItemIDs.isEmpty == false { + updateItemsAndSections( + items: filteredItems, + sections: modelContext.sections(for: filteredItems, sectionBy: sectionBy), + reason: reason, + updatedItemIDs: updatedItemIDs + ) + } + return originalCount + } + + private func updatedItemIDsAfterRemoval( + of chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) -> Set { + var ids: Set = [] + insertUpdatedItemID(chat, into: &ids) + insertUpdatedItemID(workspace, into: &ids) + insertUpdatedItemID(group, into: &ids) + return ids + } + + @discardableResult + private func applyLocalRevalidation( + _ changes: [CodexFetchedChatRevalidation], + reason: CodexFetchedResultsTransactionReason + ) -> Int { + let originalCount = items.count + var filteredItems = items + for change in changes { + filteredItems = filteredItems.filter { + shouldKeep( + $0, + afterRevalidating: change.chat, + previousWorkspace: change.previousWorkspace, + previousGroup: change.previousGroup, + archived: change.archived + ) + } + } + let sortedItems = modelContext.sortedItems(filteredItems, for: fetchDescriptor) + updateItemsAndSections( + items: sortedItems, + sections: modelContext.sections(for: sortedItems, sectionBy: sectionBy), + reason: reason, + updatedItemIDs: updatedItemIDs(for: changes) + ) + return originalCount + } + + private func updatedItemIDs(for changes: [CodexFetchedChatRevalidation]) -> Set { + var ids: Set = [] + for change in changes { + insertUpdatedItemID(change.chat, into: &ids) + insertUpdatedItemID(change.previousWorkspace, into: &ids) + insertUpdatedItemID(change.chat.workspace, into: &ids) + insertUpdatedItemID(change.previousGroup, into: &ids) + insertUpdatedItemID(change.chat.workspace?.workspaceGroup, into: &ids) + } + return ids + } + + private func insertUpdatedItemID( + _ model: (any CodexPersistentModel)?, + into ids: inout Set + ) { + guard let item = model as? Model else { + return + } + ids.insert(item.id) + } + + @discardableResult + private func refreshItems( + archived: Bool, + keeping shouldKeep: (Model) -> Bool, + reason: CodexFetchedResultsTransactionReason + ) -> Bool { + guard requestMatchesArchiveScope(archived) else { + updateItemsAndSections( + items: items, + sections: modelContext.sections(for: items, sectionBy: sectionBy), + reason: reason + ) + return false + } + let filteredItems = items.filter(shouldKeep) + updateItemsAndSections( + items: filteredItems, + sections: modelContext.sections(for: filteredItems, sectionBy: sectionBy), + reason: reason + ) + return true + } + + private func upsertLoadedModels(from workspace: CodexWorkspace) -> Bool { + for chat in workspace.chats { + guard let model = insertionModel(for: chat, archived: chat.isArchived) else { + continue + } + guard upsert(model, reason: .refresh) else { + return false + } + } + return true + } + + private func upsertLoadedModels(from group: CodexWorkspaceGroup) -> Bool { + for workspace in group.workspaces { + guard upsertLoadedModels(from: workspace) else { + return false + } + } + return true + } + + private func restoreArchivedRelationships(for chat: CodexChat) { + guard let workspace = chat.workspace else { + return + } + if workspace.chats.contains(where: { $0 === chat }) == false { + workspace.replaceContextChats([chat] + workspace.chats) + } + if let group = workspace.workspaceGroup, + group.workspaces.contains(where: { $0 === workspace }) == false + { + group.replaceContextWorkspaces(group.workspaces + [workspace]) + } + } + + private func requestMatchesArchiveScope(_ archived: Bool) -> Bool { + chatQueryPlan?.matchesArchiveScope(archived) ?? (archived == false) + } + + private func requestIsScoped(to workspace: CodexWorkspace) -> Bool { + guard let filterWorkspaces = chatQueryPlan?.workspaces else { + return false + } + return filterWorkspaces.contains { + Self.standardizedPath($0) == Self.standardizedPath(workspace.url) + } + } + + private func containsIncludedWorkspace(in group: CodexWorkspaceGroup) -> Bool { + group.workspaces.contains { containsIncludedChat(in: $0) } + } + + private func containsIncludedChat(in workspace: CodexWorkspace) -> Bool { + workspace.chats.contains { shouldInclude($0, archived: $0.isArchived) } + } + + private static func standardizedPath(_ url: URL) -> String { + url.standardizedFileURL.resolvingSymlinksInPath().path + } +} diff --git a/Sources/CodexDataKit/CodexFetchedResultsController.swift b/Sources/CodexDataKit/CodexFetchedResultsController.swift new file mode 100644 index 00000000..be7d5973 --- /dev/null +++ b/Sources/CodexDataKit/CodexFetchedResultsController.swift @@ -0,0 +1,418 @@ +import Foundation + +public struct CodexFetchedResultsIndexPath: Sendable, Hashable { + public var section: Int + public var item: Int + + public init(section: Int, item: Int) { + self.section = section + self.item = item + } +} + +public struct CodexFetchedResultsSnapshot: Sendable, Hashable { + public struct Section: Identifiable, Sendable, Hashable { + public var id: CodexFetchSectionID + public var title: String? + public var itemIDs: [ItemID] + + public init(id: CodexFetchSectionID, title: String?, itemIDs: [ItemID]) { + self.id = id + self.title = title + self.itemIDs = itemIDs + } + } + + public var sections: [Section] + + public init(sections: [Section] = []) { + self.sections = sections + } + + public var sectionIDs: [CodexFetchSectionID] { + sections.map(\.id) + } + + public var itemIDs: [ItemID] { + sections.flatMap(\.itemIDs) + } + + public func itemIDs(in sectionID: CodexFetchSectionID) -> [ItemID]? { + sections.first { $0.id == sectionID }?.itemIDs + } +} + +extension CodexFetchedResultsSnapshot { + init( + sections: [CodexFetchSection] + ) where Model.ID == ItemID { + self.init(sections: sections.map { section in + Section( + id: section.id, + title: section.title, + itemIDs: section.items.map(\.id) + ) + }) + } +} + +public enum CodexFetchedResultsSectionChange: Sendable, Hashable { + case insert(sectionID: CodexFetchSectionID, index: Int) + case delete(sectionID: CodexFetchSectionID, index: Int) + case move(sectionID: CodexFetchSectionID, from: Int, to: Int) + case update(sectionID: CodexFetchSectionID, index: Int) +} + +public enum CodexFetchedResultsItemChange: Sendable, Hashable { + case insert(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) + case delete(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) + case move( + itemID: ItemID, + from: CodexFetchedResultsIndexPath, + to: CodexFetchedResultsIndexPath + ) + case update(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) +} + +public enum CodexFetchedResultsTransactionReason: Sendable, Hashable { + case initialFetch + case refresh + case pageAppend + case insert + case archive + case remove + case revalidate +} + +public struct CodexFetchedResultsTransaction: Sendable, Hashable { + public typealias ItemID = Model.ID + + public var reason: CodexFetchedResultsTransactionReason + public var oldSnapshot: CodexFetchedResultsSnapshot + public var newSnapshot: CodexFetchedResultsSnapshot + public var sectionChanges: [CodexFetchedResultsSectionChange] + public var itemChanges: [CodexFetchedResultsItemChange] + + public var isInitialFetch: Bool { + reason == .initialFetch + } + + public var hasChanges: Bool { + sectionChanges.isEmpty == false || itemChanges.isEmpty == false + } + + public init( + reason: CodexFetchedResultsTransactionReason, + oldSnapshot: CodexFetchedResultsSnapshot, + newSnapshot: CodexFetchedResultsSnapshot, + sectionChanges: [CodexFetchedResultsSectionChange], + itemChanges: [CodexFetchedResultsItemChange] + ) { + self.reason = reason + self.oldSnapshot = oldSnapshot + self.newSnapshot = newSnapshot + self.sectionChanges = sectionChanges + self.itemChanges = itemChanges + } + + init( + reason: CodexFetchedResultsTransactionReason, + oldSnapshot: CodexFetchedResultsSnapshot, + newSnapshot: CodexFetchedResultsSnapshot, + updatedItemIDs: Set = [] + ) { + self.init( + reason: reason, + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot, + sectionChanges: Self.sectionChanges(from: oldSnapshot, to: newSnapshot), + itemChanges: Self.itemChanges( + from: oldSnapshot, + to: newSnapshot, + reason: reason, + updatedItemIDs: updatedItemIDs + ) + ) + } + + private static func sectionChanges( + from oldSnapshot: CodexFetchedResultsSnapshot, + to newSnapshot: CodexFetchedResultsSnapshot + ) -> [CodexFetchedResultsSectionChange] { + let oldIndexes = indexSections(oldSnapshot.sections) + let newIndexes = indexSections(newSnapshot.sections) + + let deletes = oldSnapshot.sections.enumerated() + .filter { _, section in newIndexes[section.id] == nil } + .sorted { $0.offset > $1.offset } + .map { index, section in + CodexFetchedResultsSectionChange.delete(sectionID: section.id, index: index) + } + + let inserts = newSnapshot.sections.enumerated() + .filter { _, section in oldIndexes[section.id] == nil } + .map { index, section in + CodexFetchedResultsSectionChange.insert(sectionID: section.id, index: index) + } + + let oldSurvivingSectionIDs = oldSnapshot.sections.map(\.id).filter { + newIndexes[$0] != nil + } + let newSurvivingSectionIDs = newSnapshot.sections.map(\.id).filter { + oldIndexes[$0] != nil + } + let oldSurvivingIndexes = indexSectionIDs(oldSurvivingSectionIDs) + let newSurvivingIndexes = indexSectionIDs(newSurvivingSectionIDs) + let moves = newSurvivingSectionIDs.compactMap { + sectionID -> CodexFetchedResultsSectionChange? in + guard oldSurvivingIndexes[sectionID] != newSurvivingIndexes[sectionID] else { + return nil + } + guard let oldIndex = oldIndexes[sectionID], let newIndex = newIndexes[sectionID] else { + return nil + } + guard oldIndex != newIndex else { + return nil + } + return CodexFetchedResultsSectionChange.move( + sectionID: sectionID, + from: oldIndex, + to: newIndex + ) + } + + let updates = newSnapshot.sections.enumerated() + .compactMap { newIndex, section -> CodexFetchedResultsSectionChange? in + guard let oldIndex = oldIndexes[section.id] else { + return nil + } + guard oldSnapshot.sections[oldIndex].title != section.title else { + return nil + } + return CodexFetchedResultsSectionChange.update(sectionID: section.id, index: newIndex) + } + + return deletes + inserts + moves + updates + } + + private static func itemChanges( + from oldSnapshot: CodexFetchedResultsSnapshot, + to newSnapshot: CodexFetchedResultsSnapshot, + reason: CodexFetchedResultsTransactionReason, + updatedItemIDs: Set + ) -> [CodexFetchedResultsItemChange] { + let oldPositions = indexItems(oldSnapshot) + let newPositions = indexItems(newSnapshot) + let oldSectionIDs = Set(oldSnapshot.sectionIDs) + let newSectionIDs = Set(newSnapshot.sectionIDs) + let deleteInsertItemIDs = itemIDsForDeleteInsert( + oldPositions: oldPositions, + newPositions: newPositions, + oldSectionIDs: oldSectionIDs, + newSectionIDs: newSectionIDs + ) + let oldRelativeIndexes = relativeIndexesByItemID( + in: oldSnapshot, + positions: oldPositions, + otherPositions: newPositions + ) + let newRelativeIndexes = relativeIndexesByItemID( + in: newSnapshot, + positions: newPositions, + otherPositions: oldPositions + ) + let reloadStableItems = reason == .refresh + + let deletes = oldPositions.values + .filter { newPositions[$0.itemID] == nil || deleteInsertItemIDs.contains($0.itemID) } + .sorted { lhs, rhs in + if lhs.indexPath.section != rhs.indexPath.section { + return lhs.indexPath.section > rhs.indexPath.section + } + return lhs.indexPath.item > rhs.indexPath.item + } + .map { + CodexFetchedResultsItemChange.delete( + itemID: $0.itemID, + indexPath: $0.indexPath + ) + } + + let inserts = newPositions.values + .filter { oldPositions[$0.itemID] == nil || deleteInsertItemIDs.contains($0.itemID) } + .sorted { lhs, rhs in + if lhs.indexPath.section != rhs.indexPath.section { + return lhs.indexPath.section < rhs.indexPath.section + } + return lhs.indexPath.item < rhs.indexPath.item + } + .map { + CodexFetchedResultsItemChange.insert( + itemID: $0.itemID, + indexPath: $0.indexPath + ) + } + + let moves = newPositions.values + .compactMap { newPosition -> CodexFetchedResultsItemChange? in + guard let oldPosition = oldPositions[newPosition.itemID] else { + return nil + } + guard deleteInsertItemIDs.contains(newPosition.itemID) == false else { + return nil + } + if oldPosition.sectionID != newPosition.sectionID { + return .move( + itemID: newPosition.itemID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ) + } + guard oldRelativeIndexes[newPosition.itemID] + != newRelativeIndexes[newPosition.itemID] + else { + return nil + } + guard oldPosition.indexPath != newPosition.indexPath else { + return nil + } + return .move( + itemID: newPosition.itemID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ) + } + .sorted { lhs, rhs in + lhs.newIndexPathForOrdering < rhs.newIndexPathForOrdering + } + + let updates = newPositions.values + .compactMap { newPosition -> CodexFetchedResultsItemChange? in + guard oldPositions[newPosition.itemID] != nil else { + return nil + } + guard deleteInsertItemIDs.contains(newPosition.itemID) == false else { + return nil + } + guard reloadStableItems || updatedItemIDs.contains(newPosition.itemID) else { + return nil + } + return .update(itemID: newPosition.itemID, indexPath: newPosition.indexPath) + } + .sorted { lhs, rhs in + lhs.newIndexPathForOrdering < rhs.newIndexPathForOrdering + } + + return deletes + inserts + moves + updates + } + + private static func itemIDsForDeleteInsert( + oldPositions: [ItemID: ItemPosition], + newPositions: [ItemID: ItemPosition], + oldSectionIDs: Set, + newSectionIDs: Set + ) -> Set { + Set(newPositions.values.compactMap { newPosition in + guard let oldPosition = oldPositions[newPosition.itemID], + oldPosition.sectionID != newPosition.sectionID + else { + return nil + } + guard newSectionIDs.contains(oldPosition.sectionID) == false + || oldSectionIDs.contains(newPosition.sectionID) == false + || oldPosition.indexPath == newPosition.indexPath + else { + return nil + } + return newPosition.itemID + }) + } + + private static func indexSections( + _ sections: [CodexFetchedResultsSnapshot.Section] + ) -> [CodexFetchSectionID: Int] { + Dictionary(uniqueKeysWithValues: sections.enumerated().map { index, section in + (section.id, index) + }) + } + + private static func indexSectionIDs( + _ sectionIDs: [CodexFetchSectionID] + ) -> [CodexFetchSectionID: Int] { + Dictionary(uniqueKeysWithValues: sectionIDs.enumerated().map { index, sectionID in + (sectionID, index) + }) + } + + private struct ItemPosition { + var itemID: ItemID + var sectionID: CodexFetchSectionID + var indexPath: CodexFetchedResultsIndexPath + } + + private static func indexItems( + _ snapshot: CodexFetchedResultsSnapshot + ) -> [ItemID: ItemPosition] { + var positions: [ItemID: ItemPosition] = [:] + for (sectionIndex, section) in snapshot.sections.enumerated() { + for (itemIndex, itemID) in section.itemIDs.enumerated() where positions[itemID] == nil { + positions[itemID] = ItemPosition( + itemID: itemID, + sectionID: section.id, + indexPath: CodexFetchedResultsIndexPath( + section: sectionIndex, + item: itemIndex + ) + ) + } + } + return positions + } + + private static func relativeIndexesByItemID( + in snapshot: CodexFetchedResultsSnapshot, + positions: [ItemID: ItemPosition], + otherPositions: [ItemID: ItemPosition] + ) -> [ItemID: Int] { + var indexes: [ItemID: Int] = [:] + for section in snapshot.sections { + var relativeIndex = 0 + for itemID in section.itemIDs { + guard let position = positions[itemID], + let otherPosition = otherPositions[itemID], + position.sectionID == otherPosition.sectionID + else { + continue + } + indexes[itemID] = relativeIndex + relativeIndex += 1 + } + } + return indexes + } +} + +extension CodexFetchedResultsItemChange { + fileprivate var newIndexPathForOrdering: CodexFetchedResultsIndexPath { + switch self { + case .insert(_, let indexPath), .update(_, let indexPath): + indexPath + case .delete(_, let indexPath): + indexPath + case .move(_, _, let indexPath): + indexPath + } + } +} + +extension CodexFetchedResultsIndexPath: Comparable { + public static func < ( + lhs: CodexFetchedResultsIndexPath, + rhs: CodexFetchedResultsIndexPath + ) -> Bool { + if lhs.section != rhs.section { + return lhs.section < rhs.section + } + return lhs.item < rhs.item + } +} diff --git a/Sources/CodexDataKit/CodexModel.swift b/Sources/CodexDataKit/CodexModel.swift new file mode 100644 index 00000000..eb2c3fbd --- /dev/null +++ b/Sources/CodexDataKit/CodexModel.swift @@ -0,0 +1,3355 @@ +import CodexAppServerKit +import Foundation +import Observation + +public protocol CodexPersistentModel: AnyObject, Observable, Hashable, Identifiable, SendableMetatype +where ID: Hashable & Sendable { + nonisolated var id: ID { get } + + var modelContext: CodexModelContext? { get } +} + +extension CodexPersistentModel { + public nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs === rhs + } + + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +public struct CodexWorkspaceID: RawRepresentable, Hashable, Sendable, Codable, + CustomStringConvertible, + ExpressibleByStringLiteral +{ + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { + rawValue + } +} + +private extension Array where Element == CodexChatMutation { + mutating func appendIfPresent(_ change: CodexChatMutation?) { + if let change { + append(change) + } + } + + var containsTurnItemMutation: Bool { + contains { $0.affectedTurnID != nil } + } +} + +private extension CodexThreadItem { + var isReviewModeMarker: Bool { + switch kind { + case .enteredReviewMode, .exitedReviewMode: + true + default: + false + } + } + + var isExitedReviewModeMarker: Bool { + kind == .exitedReviewMode + } + + var isReviewNarrativeBoundary: Bool { + switch kind { + case .userMessage, .agentMessage, .enteredReviewMode, .exitedReviewMode: + true + default: + false + } + } + + var command: CodexCommand? { + guard case .command(let command) = content else { + return nil + } + return command + } +} + +public struct CodexWorkspaceGroupID: RawRepresentable, Hashable, Sendable, Codable, + CustomStringConvertible, + ExpressibleByStringLiteral +{ + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { + rawValue + } +} + +public struct CodexChatItemID: Hashable, Sendable, Codable, CustomStringConvertible { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(rawItemID: String, turnID: CodexTurnID?) { + self.rawValue = Self.scopedRawValue(rawItemID, turnID: turnID) + } + + package init(rawItemID: String, turnID: CodexTurnID?, chatID: CodexThreadID) { + self.rawValue = Self.scopedRawValue(rawItemID, turnID: turnID, chatID: chatID) + } + + public var description: String { + rawValue + } + + fileprivate static func scopedRawValue( + _ value: String, + turnID: CodexTurnID?, + chatID: CodexThreadID? = nil + ) -> String { + if let turnID { + return "\(turnID.rawValue):\(value)" + } + if let chatID { + return "chat:\(chatID.rawValue):\(value)" + } + return value + } +} + +public struct CodexChatInput: Sendable { + public var instructions: CodexInstructions? + public var options: CodexThread.Options + + public init( + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init() + ) { + self.instructions = instructions + self.options = options + } +} + +public struct CodexReviewInput: Sendable { + public var target: CodexReviewTarget + public var instructions: CodexInstructions? + public var options: CodexThread.Options + public var delivery: CodexReviewDelivery + + public init( + target: CodexReviewTarget, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init(), + delivery: CodexReviewDelivery = .inline + ) { + self.target = target + self.instructions = instructions + self.options = options + self.delivery = delivery + } +} + +public struct CodexStartedReview { + public let chat: CodexChat + public let session: CodexReviewSession + + public init(chat: CodexChat, session: CodexReviewSession) { + self.chat = chat + self.session = session + } +} + +public struct CodexChatMessageInput: Sendable { + public var prompt: CodexPrompt + public var options: CodexGenerationOptions + + public init( + _ text: String, + options: CodexGenerationOptions = .init() + ) { + self.prompt = CodexPrompt(text) + self.options = options + } + + public init( + prompt: CodexPrompt, + options: CodexGenerationOptions = .init() + ) { + self.prompt = prompt + self.options = options + } +} + +@Observable +public final class CodexWorkspaceGroup: CodexPersistentModel { + public let id: CodexWorkspaceGroupID + public private(set) var name: String + public private(set) var workspaces: [CodexWorkspace] + + @ObservationIgnored + public private(set) weak var modelContext: CodexModelContext? + + package init( + id: CodexWorkspaceGroupID, + name: String, + modelContext: CodexModelContext + ) { + self.id = id + self.name = name + self.workspaces = [] + self.modelContext = modelContext + } + + package func applyContextSnapshot(name: String) { + self.name = name + } + + package func replaceContextWorkspaces(_ workspaces: [CodexWorkspace]) { + self.workspaces = workspaces + } + +} + +private extension CodexTurnStatus { + var isTerminal: Bool { + switch self { + case .inProgress, .unknown: + false + case .completed, .failed, .interrupted: + true + } + } +} + +@Observable +public final class CodexWorkspace: CodexPersistentModel { + public let id: CodexWorkspaceID + public private(set) var url: URL + public private(set) var name: String + public private(set) var chats: [CodexChat] + + public private(set) weak var workspaceGroup: CodexWorkspaceGroup? + + public var workspaceGroupID: CodexWorkspaceGroupID? { + workspaceGroup?.id + } + + @ObservationIgnored + public private(set) weak var modelContext: CodexModelContext? + + package init( + id: CodexWorkspaceID, + url: URL, + name: String, + workspaceGroup: CodexWorkspaceGroup?, + modelContext: CodexModelContext + ) { + self.id = id + self.url = url + self.name = name + self.workspaceGroup = workspaceGroup + self.chats = [] + self.modelContext = modelContext + } + + package func applyContextSnapshot( + url: URL, + name: String, + workspaceGroup: CodexWorkspaceGroup? + ) { + self.url = url + self.name = name + self.workspaceGroup = workspaceGroup + } + + package func replaceContextChats(_ chats: [CodexChat]) { + self.chats = chats + } + + package func attachContextChatIfNeeded(_ chat: CodexChat) { + guard chats.contains(where: { $0 === chat }) == false else { + return + } + chats.append(chat) + } + + package func moveContextChatToFront(_ chat: CodexChat) { + chats.removeAll { $0 === chat } + chats.insert(chat, at: 0) + } + + @discardableResult + public nonisolated(nonsending) func startChat( + _ input: CodexChatInput = .init() + ) async throws -> CodexChat { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + return try await modelContext.startChat(in: self, input: input) + } + + @discardableResult + public nonisolated(nonsending) func startReview( + _ input: CodexReviewInput + ) async throws -> CodexStartedReview { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + return try await modelContext.startReview(in: self, input: input) + } +} + +@Observable +public final class CodexTurn: CodexPersistentModel { + public let id: CodexTurnID + public var state: CodexTurnSnapshot.State? + public var status: CodexTurnStatus? { + switch state { + case .inProgress: .inProgress + case .completed: .completed + case .interrupted: .interrupted + case .failed: .failed + case .unknown(let rawValue, _): .unknown(rawValue: rawValue) + case nil: nil + } + } + public var error: CodexTurnError? { + switch state { + case .failed(let error), .unknown(_, let error?): error + case .inProgress, .completed, .interrupted, .unknown(_, nil), nil: nil + } + } + public var itemsLoadState: CodexTurnItemsLoadState + public var usage: CodexTokenUsage? + public private(set) var items: [CodexItem] + + public private(set) weak var chat: CodexChat? + + @ObservationIgnored + public private(set) weak var modelContext: CodexModelContext? + + package init( + id: CodexTurnID, + chat: CodexChat, + modelContext: CodexModelContext, + state: CodexTurnSnapshot.State? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil, + usage: CodexTokenUsage? = nil + ) { + self.id = id + self.chat = chat + self.modelContext = modelContext + self.state = state + self.itemsLoadState = itemsLoadState ?? .notLoaded + self.usage = usage + self.items = [] + } + + package func applyContextChat(_ chat: CodexChat) { + self.chat = chat + } + + package func replaceContextItems(_ items: [CodexItem]) { + self.items = items + } + + package func attachContextItemIfNeeded(_ item: CodexItem) { + guard items.contains(where: { $0 === item }) == false else { + return + } + items.append(item) + } + + package func detachContextItem(_ item: CodexItem) { + items.removeAll { $0 === item } + } + + package func detachFromContext() { + chat = nil + modelContext = nil + items = [] + } +} + +@Observable +public final class CodexItem: CodexPersistentModel { + public let id: CodexChatItemID + public private(set) var itemID: String + public fileprivate(set) var itemsLoadState: CodexTurnItemsLoadState + public var kind: CodexThreadItem.Kind + public var content: CodexThreadItem.Content + public private(set) var origin: CodexThreadItem.Origin + public private(set) var semanticRelation: CodexThreadItem.SemanticRelation? + public var rawPayload: Data? + + public private(set) weak var chat: CodexChat? + public private(set) weak var turn: CodexTurn? + + @ObservationIgnored + public private(set) weak var modelContext: CodexModelContext? + + public var turnID: CodexTurnID? { + turn?.id + } + + public var text: String? { + threadItem.text + } + + public var message: CodexMessage? { + threadItem.message + } + + public var reasoning: CodexReasoning? { + if case .reasoning(let reasoning) = content { + return reasoning + } + return nil + } + + fileprivate var threadItem: CodexThreadItem { + CodexThreadItem( + id: itemID, + kind: kind, + content: content, + origin: origin, + semanticRelation: semanticRelation, + rawPayload: rawPayload + ) + } + + fileprivate var mergeKey: CodexChatItemKey { + .init(id: itemID, kind: kind, turnID: turnID) + } + + fileprivate var isExitedReviewModeMarker: Bool { + threadItem.isExitedReviewModeMarker + } + + package init( + threadItem: CodexThreadItem, + chat: CodexChat, + turn: CodexTurn?, + modelContext: CodexModelContext, + itemsLoadState: CodexTurnItemsLoadState + ) { + self.id = CodexChatItemKey(threadItem: threadItem, turnID: turn?.id).modelID(in: chat.id) + self.itemID = threadItem.id + self.chat = chat + self.turn = turn + self.modelContext = modelContext + self.itemsLoadState = itemsLoadState + self.kind = threadItem.kind + self.content = threadItem.content + self.origin = threadItem.origin + self.semanticRelation = threadItem.semanticRelation + self.rawPayload = threadItem.rawPayload + } + + package func applyContextOwners(chat: CodexChat, turn: CodexTurn?) { + self.chat = chat + self.turn = turn + } + + package func detachFromContext() { + chat = nil + turn = nil + modelContext = nil + } + + fileprivate func update( + from threadItem: CodexThreadItem, + itemsLoadState: CodexTurnItemsLoadState + ) { + itemID = threadItem.id + self.itemsLoadState = itemsLoadState + kind = threadItem.kind + content = threadItem.content + origin = threadItem.origin + semanticRelation = threadItem.semanticRelation + rawPayload = threadItem.rawPayload + } +} + +package struct CodexChatItemKey: Hashable { + var id: String + var kind: CodexThreadItem.Kind? + var semanticID: String + var turnID: CodexTurnID? + + init(id: String, kind: CodexThreadItem.Kind? = nil, turnID: CodexTurnID?) { + self.id = id + self.kind = kind + self.semanticID = Self.semanticID(rawItemID: id, kind: kind) + self.turnID = turnID + } + + init(threadItem: CodexThreadItem, turnID: CodexTurnID?) { + self.init(id: threadItem.id, kind: threadItem.kind, turnID: turnID) + } + + var modelID: CodexChatItemID { + CodexChatItemID(rawItemID: semanticID, turnID: turnID) + } + + func modelID(in chatID: CodexThreadID) -> CodexChatItemID { + CodexChatItemID(rawItemID: semanticID, turnID: turnID, chatID: chatID) + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.kind == rhs.kind + && lhs.semanticID == rhs.semanticID + && lhs.turnID == rhs.turnID + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(semanticID) + hasher.combine(turnID) + } + + private static func semanticID( + rawItemID: String, + kind: CodexThreadItem.Kind? + ) -> String { + switch kind { + case .some(let kind): + "\(kind.rawValue):\(rawItemID)" + default: + rawItemID + } + } +} + +package enum CodexThreadListSourcePossibility: Hashable, Sendable { + case kind(CodexThreadSourceKind) + case supportedCustomInteractive + + package var projectedSourceKind: CodexThreadSourceKind? { + switch self { + case .kind(let sourceKind): + sourceKind + case .supportedCustomInteractive: + nil + } + } +} + +package struct CodexThreadListSourceProvenance: Hashable, Sendable { + package let possibilities: Set + + package init(sourceKinds: [CodexThreadSourceKind]?) { + let possibilities: Set + if let sourceKinds, sourceKinds.isEmpty == false { + possibilities = Set(sourceKinds.flatMap { sourceKind -> [CodexThreadListSourcePossibility] in + if sourceKind == .subAgent { + return [ + .kind(.subAgent), + .kind(.subAgentReview), + .kind(.subAgentCompact), + .kind(.subAgentThreadSpawn), + .kind(.subAgentOther), + ] + } + return [.kind(sourceKind)] + }) + } else { + possibilities = [ + .kind(.cli), + .kind(.vscode), + .supportedCustomInteractive, + ] + } + precondition( + possibilities.isEmpty == false, + "A thread-list source provenance must contain at least one possibility." + ) + self.possibilities = possibilities + } + + package func intersecting(_ other: Self) -> Self { + let intersection = possibilities.intersection(other.possibilities) + precondition( + intersection.isEmpty == false, + "A thread cannot belong to disjoint thread-list source partitions." + ) + return Self(possibilities: intersection) + } + + private init(possibilities: Set) { + precondition( + possibilities.isEmpty == false, + "A thread-list source provenance must contain at least one possibility." + ) + self.possibilities = possibilities + } +} + +package enum CodexThreadSourceResolution: Hashable, Sendable { + case unresolved + case partitionProven(CodexThreadListSourceProvenance) + case exact(CodexThreadSessionSource) + case kindOnly(CodexThreadSourceKind) + case knownNull + + package var source: CodexThreadSessionSource? { + guard case .exact(let source) = self else { + return nil + } + return source + } + + package var sourceKind: CodexThreadSourceKind? { + switch self { + case .exact(let source): + source.sourceKind + case .kindOnly(let sourceKind): + sourceKind + case .unresolved, .partitionProven, .knownNull: + nil + } + } + + package var partitionProvenance: CodexThreadListSourceProvenance? { + guard case .partitionProven(let provenance) = self else { + return nil + } + return provenance + } + + package mutating func apply( + _ snapshot: CodexThreadSnapshot, + partitionProvenance: CodexThreadListSourceProvenance? + ) { + if snapshot.hasField(.source) { + self = snapshot.source.map(Self.exact) ?? .knownNull + return + } + if snapshot.hasField(.sourceKind) { + let sourceKind = snapshot.sourceKind + if case .exact(let source) = self, source.sourceKind == sourceKind { + return + } + self = sourceKind.map(Self.kindOnly) ?? .knownNull + return + } + guard let partitionProvenance else { + return + } + switch self { + case .unresolved: + self = .partitionProven(partitionProvenance) + case .partitionProven(let existing): + self = .partitionProven(existing.intersecting(partitionProvenance)) + case .exact, .kindOnly, .knownNull: + break + } + } +} + +@Observable +public final class CodexChat: CodexPersistentModel { + public let id: CodexThreadID + public private(set) var name: String? + public private(set) var preview: String? + public private(set) var modelProvider: String? + /// The app-server session identifier, when present in the latest snapshot. + public private(set) var sessionID: String? + /// The direct parent thread identifier reported by the app-server. + public private(set) var parentThreadID: CodexThreadID? + private var sourceResolution: CodexThreadSourceResolution + /// The exact thread session origin reported by the app-server. + public var source: CodexThreadSessionSource? { + sourceResolution.source + } + /// A coarse source projection retained for source-kind filtering compatibility. + /// + /// Exact custom sources project to `nil`. Fetch predicates must narrow this + /// property to a finite set of non-`nil` kinds; an unbounded `nil` or non-`nil` + /// comparison cannot be represented by the app-server and fails validation. + public var sourceKind: CodexThreadSourceKind? { + sourceResolution.sourceKind + } + package var threadListSourceProvenance: CodexThreadListSourceProvenance? { + sourceResolution.partitionProvenance + } + package var threadSourceResolution: CodexThreadSourceResolution { + sourceResolution + } + /// Git repository metadata captured for this thread by the app-server. + public private(set) var gitInfo: CodexThreadGitInfo? + public private(set) var isArchived: Bool + public private(set) var createdAt: Date? + public private(set) var updatedAt: Date? + public private(set) var recencyAt: Date? + public private(set) var status: CodexThreadStatus? + public private(set) var ephemeral: Bool? + public private(set) var turns: [CodexTurn] + public private(set) var items: [CodexItem] + public private(set) var phase: CodexChatPhase = .idle + + public private(set) weak var workspace: CodexWorkspace? + + public var workspaceID: CodexWorkspaceID? { + workspace?.id + } + + public var workspaceGroupID: CodexWorkspaceGroupID? { + workspace?.workspaceGroupID + } + + @ObservationIgnored + private var liveMergeState = LiveMergeState() + @ObservationIgnored + private var hasAppliedLiveTurnItemUpdates = false + @ObservationIgnored + private var preservesSeededMetadataUntilAuthoritativeSnapshot = false + @ObservationIgnored + private var turnsByID: [CodexTurnID: CodexTurn] = [:] + @ObservationIgnored + private var itemsByMergeKey: [CodexChatItemKey: CodexItem] = [:] + @ObservationIgnored + private var itemsByTurnID: [CodexTurnID: [CodexItem]] = [:] + @ObservationIgnored + private var provisionalSeedTurnID: CodexTurnID? + // Unlike provisionalSeedTurnID (single-shot, consumed by the first live + // event), this survives for the whole review turn so authoritative + // records with fully synthesized identities can still be adopted into it. + @ObservationIgnored + private var seededReviewTurnID: CodexTurnID? + + @ObservationIgnored + public private(set) weak var modelContext: CodexModelContext? + + public var title: String { + if let name, name.isEmpty == false { + return name + } + if let preview, preview.isEmpty == false { + return preview + } + if let workspace { + return workspace.name + } + return id.rawValue + } + + public var transcript: CodexTranscript { + .init(items: items.map(\.threadItem)) + } + + public var searchableText: String { + [ + name, + preview, + workspace?.name, + title, + ] + .compactMap { $0 } + .joined(separator: "\n") + } + + public func turn(id: CodexTurnID) -> CodexTurn? { + // Keep Observation dependency tracking on the ordered current value while + // serving the lookup from the ignored index. + _ = turns + return turnsByID[id] + } + + public func items(in turnID: CodexTurnID) -> [CodexItem] { + // Keep Observation dependency tracking on the ordered current value while + // serving the scoped lookup from the ignored index. + _ = items + return itemsByTurnID[turnID] ?? [] + } + + /// Returns the context-owned transcript projection for one loaded turn. + public func transcript(in turnID: CodexTurnID) -> CodexTranscript { + .init(items: items(in: turnID).map(\.threadItem)) + } + + package init( + id: CodexThreadID, + modelContext: CodexModelContext + ) { + self.id = id + self.turns = [] + self.items = [] + self.sourceResolution = .unresolved + self.isArchived = false + self.modelContext = modelContext + } + + package func apply( + _ snapshot: CodexThreadSnapshot, + workspace: CodexWorkspace?, + sourceProvenance: CodexThreadListSourceProvenance? = nil, + preservesExistingTurnItems: Bool = false + ) { + if snapshot.hasField(.workspace) { + self.workspace = workspace + } + let receivedAuthoritativeTitleMetadata = + (snapshot.hasField(.name) && snapshot.name?.isEmpty == false) + || (snapshot.hasField(.preview) && snapshot.preview?.isEmpty == false) + if snapshot.hasField(.name), shouldApplyOptionalMetadata(snapshot.name, existing: name) { + name = snapshot.name + } + if snapshot.hasField(.preview), shouldApplyOptionalMetadata(snapshot.preview, existing: preview) { + preview = snapshot.preview + } + if snapshot.hasField(.modelProvider), + shouldApplyOptionalMetadata(snapshot.modelProvider, existing: modelProvider) + { + modelProvider = snapshot.modelProvider + } + if snapshot.hasField(.sessionID) { + sessionID = snapshot.sessionID + } + if snapshot.hasField(.parentThreadID) { + parentThreadID = snapshot.parentThreadID + } + sourceResolution.apply(snapshot, partitionProvenance: sourceProvenance) + if snapshot.hasField(.gitInfo) { + gitInfo = snapshot.gitInfo + } + if receivedAuthoritativeTitleMetadata { + preservesSeededMetadataUntilAuthoritativeSnapshot = false + } + if snapshot.hasField(.createdAt) { + createdAt = snapshot.createdAt + } + if snapshot.hasField(.updatedAt) { + updatedAt = snapshot.updatedAt + } + if snapshot.hasField(.recencyAt) { + recencyAt = snapshot.recencyAt + } + if snapshot.hasField(.status) { + status = snapshot.status + } + if snapshot.hasField(.ephemeral) { + ephemeral = snapshot.ephemeral + } + if let turns = snapshot.turns { + let preservesSeededReviewTurnItems = shouldPreserveSeededReviewTurnItemsWhenReconcilingSnapshot + let replacesTurnItems = snapshot.turnItemsAreAuthoritative + && preservesExistingTurnItems == false + && preservesSeededReviewTurnItems == false + let turns = normalizedIncomingTurnRecords( + turns, + usesLoadedReviewHistory: replacesTurnItems == false + ) + if replacesTurnItems { + replaceTurns(with: turns) + replaceItems(with: turns) + hasAppliedLiveTurnItemUpdates = false + } else { + mergeTurns(with: turns) + mergeItems( + from: turns, + preservesSeededReviewTurnItems: preservesSeededReviewTurnItems + ) + } + for turn in turns { + if turn.status.isTerminal { + _ = terminalizeActiveItems( + in: turn.id, + status: turn.status + ) + } + } + } + } + + package func applyContextArchived(_ isArchived: Bool) { + self.isArchived = isArchived + } + + package func preserveSeededMetadataUntilAuthoritativeSnapshot() { + preservesSeededMetadataUntilAuthoritativeSnapshot = true + } + + package func markProvisionalSeedTurn(_ turnID: CodexTurnID?) { + provisionalSeedTurnID = turnID + if let turnID { + seededReviewTurnID = turnID + } + } + + package func detachFromContext() { + for item in items { + item.detachFromContext() + } + for turn in turns { + for item in turn.items { + item.detachFromContext() + } + turn.detachFromContext() + } + turns = [] + items = [] + turnsByID = [:] + itemsByMergeKey = [:] + itemsByTurnID = [:] + liveMergeState = LiveMergeState() + provisionalSeedTurnID = nil + seededReviewTurnID = nil + workspace = nil + modelContext = nil + } + + package func detachFromWorkspace(_ workspace: CodexWorkspace) { + if self.workspace === workspace { + self.workspace = nil + } + } + + public func observe( + includeTurns: Bool = true, + isolation: isolated any Actor = #isolation + ) async throws -> CodexChatObservation { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + return try await modelContext.observe( + self, + includeTurns: includeTurns, + isolation: isolation + ) + } + + private func shouldApplyOptionalMetadata(_ incoming: Value?, existing: Value?) -> Bool { + incoming != nil || preservesSeededMetadataUntilAuthoritativeSnapshot == false || existing == nil + } + + @discardableResult + public nonisolated(nonsending) func send( + _ input: CodexChatMessageInput + ) async throws -> CodexTurnOutcome { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + let stablePhase = phase + phase = .loading + do { + let response = try await modelContext.send(input, in: self) + await modelContext.syncPhaseAfterSend(in: self) + return response + } catch is CancellationError { + restorePhaseIfLoading(stablePhase) + throw CancellationError() + } catch { + fail(with: error) + throw error + } + } + + @discardableResult + public nonisolated(nonsending) func send( + _ text: String, + options: CodexGenerationOptions = .init() + ) async throws -> CodexTurnOutcome { + try await send(.init(text, options: options)) + } + + public nonisolated(nonsending) func cancel() async throws { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + try await modelContext.cancelActiveTurn(in: self) + } + + public nonisolated(nonsending) func archive() async throws { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + try await modelContext.archive(self) + } + + public nonisolated(nonsending) func unarchive() async throws { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + try await modelContext.unarchive(self) + } + + public nonisolated(nonsending) func delete() async throws { + guard let modelContext else { + throw CodexModelContextError.modelIsDetached + } + try await modelContext.delete(self) + } + + private func contextTurn( + id: CodexTurnID, + state: CodexTurnSnapshot.State? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil, + usage: CodexTokenUsage? = nil + ) -> CodexTurn { + guard let modelContext else { + preconditionFailure("CodexChat is detached from its CodexModelContext.") + } + return modelContext.turn( + id: id, + in: self, + state: state, + itemsLoadState: itemsLoadState, + usage: usage + ) + } + + private func contextItem( + threadItem: CodexThreadItem, + turnID: CodexTurnID?, + itemsLoadState: CodexTurnItemsLoadState + ) -> CodexItem { + guard let modelContext else { + preconditionFailure("CodexChat is detached from its CodexModelContext.") + } + return modelContext.item( + threadItem: threadItem, + turnID: turnID, + in: self, + itemsLoadState: itemsLoadState + ) + } + + private func replaceTurns(with records: [CodexTurnSnapshot]) { + provisionalSeedTurnID = nil + let existingByID = Dictionary(uniqueKeysWithValues: turns.map { ($0.id, $0) }) + turns = records.map { record in + let turn = existingByID[record.id] ?? contextTurn(id: record.id) + turn.state = record.state + turn.itemsLoadState = record.itemsLoadState + return turn + } + rebuildTurnIndex() + } + + private func normalizedIncomingTurnRecords( + _ records: [CodexTurnSnapshot], + usesLoadedReviewHistory: Bool + ) -> [CodexTurnSnapshot] { + let normalized = recordsByRemovingReplacedProvisionalSeed(records).map { record in + var record = record + if let liveTurnID = liveTurnID(adopting: record) { + record.id = liveTurnID + } + return record + } + var recordsByID: [CodexTurnID: Int] = [:] + var coalesced: [CodexTurnSnapshot] = [] + coalesced.reserveCapacity(normalized.count) + for record in normalized { + guard let index = recordsByID[record.id] else { + recordsByID[record.id] = coalesced.count + coalesced.append(record) + continue + } + coalesced[index] = coalescing(coalesced[index], with: record) + } + return normalizingReviewRolloutCompanions( + coalesced.map(normalizingLifecycleFromItemOrder), + usesLoadedReviewHistory: usesLoadedReviewHistory + ) + } + + private func normalizingReviewRolloutCompanions( + _ records: [CodexTurnSnapshot], + usesLoadedReviewHistory: Bool + ) -> [CodexTurnSnapshot] { + var normalized = records + let supersededTurnIDs = Set( + records.lazy.filter(\.itemsAreAuthoritative).map(\.id) + ) + for candidateIndex in normalized.indices { + normalized[candidateIndex] = normalizingReviewRolloutCompanion( + normalized[candidateIndex], + hasPrecedingReviewExit: hasPrecedingReviewExit( + before: candidateIndex, + in: normalized, + usesLoadedReviewHistory: usesLoadedReviewHistory, + supersededTurnIDs: supersededTurnIDs + ) + ) + } + return normalized + } + + private func normalizingReviewRolloutCompanion( + _ record: CodexTurnSnapshot, + hasPrecedingReviewExit: Bool + ) -> CodexTurnSnapshot { + var normalized = record + if let agentIndex = sameTurnReviewCompanionAgentIndex(in: normalized) { + normalized.items[agentIndex] = reviewRolloutCompanion( + normalized.items[agentIndex] + ) + } else if let agentIndex = persistedReviewCompanionAgentIndex(in: normalized), + hasPrecedingReviewExit + { + normalized.items[agentIndex] = reviewRolloutCompanion( + normalized.items[agentIndex] + ) + } + return normalized + } + + private func sameTurnReviewCompanionAgentIndex( + in record: CodexTurnSnapshot + ) -> Int? { + guard record.itemsLoadState == .full else { + return nil + } + for index in record.items.indices + where record.items[index].kind == .agentMessage + && record.items[index].semanticRelation == nil + { + let precedingNarrativeItems = record.items[.. Int? { + guard record.status.isTerminal, + record.itemsLoadState == .full + else { + return nil + } + let userMessages = record.items.filter { $0.kind == .userMessage } + let agentIndices = record.items.indices.filter { + record.items[$0].kind == .agentMessage + } + guard userMessages.count == 2, + agentIndices.count == 1, + userMessages.allSatisfy({ normalizedMessageText($0) != nil }), + Set(userMessages.compactMap(normalizedMessageText)).count == 1, + normalizedMessageText(record.items[agentIndices[0]]) != nil, + record.items.contains(where: \.isReviewModeMarker) == false + else { + return nil + } + return agentIndices[0] + } + + private func hasPrecedingReviewExit( + before candidateIndex: Int, + in records: [CodexTurnSnapshot], + usesLoadedReviewHistory: Bool, + supersededTurnIDs: Set + ) -> Bool { + if candidateIndex > records.startIndex { + for record in records[.. String? { + guard let text = item.message?.text.trimmingCharacters(in: .whitespacesAndNewlines), + text.isEmpty == false + else { + return nil + } + return text + } + + private func reviewRolloutCompanion(_ item: CodexThreadItem) -> CodexThreadItem { + CodexThreadItem( + id: item.id, + kind: item.kind, + content: item.content, + origin: .reviewRolloutAssistant, + semanticRelation: .companionOf(.exitedReviewMode), + rawPayload: item.rawPayload + ) + } + + private func normalizingLifecycleFromItemOrder( + _ record: CodexTurnSnapshot + ) -> CodexTurnSnapshot { + var record = record + var normalizedItems: [CodexThreadItem] = [] + normalizedItems.reserveCapacity(record.items.count) + let inferredStatus = record.status.isTerminal ? record.status : .completed + var activeLifecycleItemIndex: Int? + for incomingItem in record.items { + if let activeLifecycleItemIndex { + normalizedItems[activeLifecycleItemIndex] = itemByApplyingTerminalLifecycleStatus( + inferredStatus, + to: normalizedItems[activeLifecycleItemIndex] + ) + } + normalizedItems.append(incomingItem) + activeLifecycleItemIndex = hasActiveLifecycleStatus(incomingItem) + ? normalizedItems.index(before: normalizedItems.endIndex) + : nil + } + record.items = normalizedItems + return record + } + + private func coalescing( + _ existing: CodexTurnSnapshot, + with incoming: CodexTurnSnapshot + ) -> CodexTurnSnapshot { + precondition(existing.id == incoming.id) + return CodexTurnSnapshot( + id: existing.id, + state: incoming.state, + itemsLoadState: mostCompleteItemsLoadState( + existing.itemsLoadState, + incoming.itemsLoadState + ), + items: coalescingItems(existing.items, with: incoming.items, turnID: existing.id), + startedAt: earliest(existing.startedAt, incoming.startedAt), + completedAt: latest(existing.completedAt, incoming.completedAt), + duration: incoming.duration ?? existing.duration + ) + } + + private func coalescingItems( + _ existing: [CodexThreadItem], + with incoming: [CodexThreadItem], + turnID: CodexTurnID + ) -> [CodexThreadItem] { + var items: [CodexThreadItem] = [] + var itemIndexByKey: [CodexChatItemKey: Int] = [:] + for item in existing + incoming { + let key = CodexChatItemKey(threadItem: item, turnID: turnID) + if let index = itemIndexByKey[key] { + items[index] = item + } else { + itemIndexByKey[key] = items.count + items.append(item) + } + } + return items + } + + private func mostCompleteItemsLoadState( + _ lhs: CodexTurnItemsLoadState, + _ rhs: CodexTurnItemsLoadState + ) -> CodexTurnItemsLoadState { + switch (lhs, rhs) { + case (.full, _), (_, .full): + .full + case (.summary, _), (_, .summary): + .summary + case (.notLoaded, .notLoaded): + .notLoaded + } + } + + private func earliest(_ lhs: Date?, _ rhs: Date?) -> Date? { + switch (lhs, rhs) { + case (.some(let lhs), .some(let rhs)): + min(lhs, rhs) + case (.some(let value), .none), (.none, .some(let value)): + value + case (.none, .none): + nil + } + } + + private func latest(_ lhs: Date?, _ rhs: Date?) -> Date? { + switch (lhs, rhs) { + case (.some(let lhs), .some(let rhs)): + max(lhs, rhs) + case (.some(let value), .none), (.none, .some(let value)): + value + case (.none, .none): + nil + } + } + + // Review turns have no persisted turn boundary in the app-server rollout, + // so an authoritative snapshot can return a turn this chat already tracks + // under its live turn id using a synthesized id. Fold such records into + // the live turn by shared item identity so one logical turn never splits + // into two turn ids; live events keep routing to the live id. + private func liveTurnID(adopting record: CodexTurnSnapshot) -> CodexTurnID? { + guard turnsByID[record.id] == nil else { + return nil + } + // A terminal reviewer record is an authoritative turn boundary. Its + // index-based narrative ids can collide with the still-open seed, so + // only a record carrying a review marker may anchor back to that seed. + let rejectsSeedMatch = record.status.isTerminal + && record.items.contains(where: \.isReviewModeMarker) == false + && seededReviewTurnID.flatMap { turnsByID[$0]?.status?.isTerminal } == false + for incomingItem in record.items { + guard let match = items.first(where: { item in + item.turnID != nil + && item.turnID != record.id + && item.kind == incomingItem.kind + && item.itemID == incomingItem.id + }) else { + continue + } + if rejectsSeedMatch, match.turnID == seededReviewTurnID { + continue + } + return match.turnID + } + return liveSeededReviewTurnID(adopting: record) + } + + // The rollout materializes a running review turn with fully synthesized + // identities: the turn id is regenerated per read and narrative items get + // index-based ids, so the record can share nothing with the seeded/live + // turn. While the seeded review turn is non-terminal, adopt never-seen + // records into it. Records carrying an exitedReviewMode item are prior + // reviews' turns and stay separate; their re-reads are stabilized by the + // shared-item fold above. + private func liveSeededReviewTurnID(adopting record: CodexTurnSnapshot) -> CodexTurnID? { + guard let reviewTurnID = seededReviewTurnID, + reviewTurnID != record.id, + let reviewTurn = turnsByID[reviewTurnID], + reviewTurn.status?.isTerminal != true, + record.status.isTerminal == false, + record.items.contains(where: { $0.kind == .exitedReviewMode }) == false + else { + return nil + } + return reviewTurnID + } + + private func recordsByRemovingReplacedProvisionalSeed( + _ records: [CodexTurnSnapshot] + ) -> [CodexTurnSnapshot] { + guard let provisionalTurnID = provisionalSeedTurnID else { + return records + } + guard records.contains(where: { record in + record.id != provisionalTurnID && record.items.contains(where: \.isReviewModeMarker) + }) else { + return records + } + _ = removeProvisionalSeedTurn(provisionalTurnID) + return records.filter { $0.id != provisionalTurnID } + } + + private func mergeTurns(with records: [CodexTurnSnapshot]) { + for record in records { + upsertTurn( + id: record.id, + state: record.state, + itemsLoadState: record.itemsLoadState, + preservesExistingUsage: true + ) + } + } + + private func replaceItems(with records: [CodexTurnSnapshot]) { + let existingByKey = itemsByMergeKey + let previousItems = items + var reusedItems = Set() + items = records.flatMap { record in + record.items.map { incomingItem in + let incomingKey = CodexChatItemKey( + threadItem: incomingItem, + turnID: record.id + ) + let turn = contextTurn(id: record.id) + if let existing = existingByKey[incomingKey] { + let identifier = ObjectIdentifier(existing) + guard reusedItems.insert(identifier).inserted else { + return contextItem( + threadItem: incomingItem, + turnID: record.id, + itemsLoadState: record.itemsLoadState + ) + } + let previousMergeKey = existing.mergeKey + existing.applyContextOwners(chat: self, turn: turn) + existing.update( + from: incomingItem, + itemsLoadState: record.itemsLoadState + ) + migrateItemIdentity(existing, from: previousMergeKey) + return existing + } + return contextItem( + threadItem: incomingItem, + turnID: record.id, + itemsLoadState: record.itemsLoadState + ) + } + } + let retainedItems = Set(items.map(ObjectIdentifier.init)) + let removedItems = previousItems.filter { + retainedItems.contains(ObjectIdentifier($0)) == false + } + unregisterItemsFromContext(removedItems) + rebuildItemIndexes() + } + + private func mergeItems( + from records: [CodexTurnSnapshot], + preservesSeededReviewTurnItems: Bool = false + ) { + for record in records { + if record.itemsAreAuthoritative { + removeItemsOmittedFromAuthoritativeSnapshot( + record.items, + turnID: record.id, + preservesOmittedSeededReviewLogItems: shouldPreserveOmittedSeededReviewLogItems( + in: record, + enabled: preservesSeededReviewTurnItems + ) + ) + } + guard record.items.isEmpty == false else { + continue + } + mergeItems( + record.items, + turnID: record.id, + reviewCompanionEvidence: .snapshotBatch, + itemsLoadState: record.itemsLoadState + ) + } + } + + private func shouldPreserveOmittedSeededReviewLogItems( + in record: CodexTurnSnapshot, + enabled: Bool + ) -> Bool { + guard enabled, + let seededReviewTurnID, + record.id == seededReviewTurnID + else { + return false + } + return true + } + + @discardableResult + private func upsertTurn( + id: CodexTurnID, + state: CodexTurnSnapshot.State?, + itemsLoadState: CodexTurnItemsLoadState? = nil, + usage: CodexTokenUsage? = nil, + preservesExistingUsage: Bool = false + ) -> CodexChatMutation? { + if let turn = turnsByID[id] { + let previousState = turn.state + let previousUsage = turn.usage + let previousItemsLoadState = turn.itemsLoadState + turn.state = state + if let itemsLoadState { + turn.itemsLoadState = itemsLoadState + } + if preservesExistingUsage == false || usage != nil { + turn.usage = usage + } + guard turn.state != previousState + || turn.usage != previousUsage + || turn.itemsLoadState != previousItemsLoadState + else { + return nil + } + return .turnUpdated(id: turn.id) + } else { + let turn = contextTurn( + id: id, + state: state, + itemsLoadState: itemsLoadState, + usage: usage + ) + turns.append(turn) + turnsByID[turn.id] = turn + return .turnInserted(id: turn.id) + } + } + + @discardableResult + package func apply(_ outcome: CodexTurnOutcome) -> [CodexChatMutation] { + let previousPhase = phase + var changes: [CodexChatMutation] = [] + let response = outcome.response + let state: CodexTurnSnapshot.State + switch outcome { + case .completed: + state = .completed + case .interrupted: + state = .interrupted + case .failed(let failedTurn): + state = .failed(failedTurn.error) + case .invalidTerminalStatus(let rawStatus, let error, _): + state = .unknown(rawValue: rawStatus, error: error) + } + if let completedAt = response.completedAt, + updatedAt.map({ completedAt > $0 }) ?? true + { + updatedAt = completedAt + } + let terminalItemsLoadState = mostCompleteItemsLoadState( + turnsByID[response.turnID]?.itemsLoadState ?? .notLoaded, + response.transcriptItemsLoadState + ) + changes.appendIfPresent(upsertTurn( + id: response.turnID, + state: state, + itemsLoadState: terminalItemsLoadState, + usage: response.usage, + preservesExistingUsage: true + )) + if response.transcriptItemsLoadState == .full { + changes.append(contentsOf: removeItemsOmittedFromAuthoritativeSnapshot( + response.transcript.items, + turnID: response.turnID + )) + } + changes.append(contentsOf: mergeItems( + response.transcript.items, + turnID: response.turnID, + reviewCompanionEvidence: .orderedItems, + itemsLoadState: response.transcriptItemsLoadState + )) + changes.append(contentsOf: normalizeReviewRolloutCompanion( + in: response.turnID + )) + if let terminalStatus = turnsByID[response.turnID]?.status { + changes.append(contentsOf: terminalizeActiveItems( + in: response.turnID, + status: terminalStatus + )) + } + changes.appendIfPresent(markIdleIfActive()) + phase = .terminal( + turnID: response.turnID, + disposition: outcome.chatTerminalDisposition + ) + appendPhaseChange(to: &changes, previousPhase: previousPhase) + markAppliedLiveTurnItemUpdatesIfNeeded(changes) + return changes + } + + @discardableResult + package func apply(_ event: CodexThreadEvent) -> [CodexChatMutation] { + let previousPhase = phase + var changes: [CodexChatMutation] = [] + switch event { + case .turnStarted(let turnID): + removeProvisionalSeedTurnIfNeeded(for: turnID, into: &changes) + changes.appendIfPresent(upsertTurn( + id: turnID, + state: .inProgress, + preservesExistingUsage: true + )) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .snapshot(let incomingSnapshot): + changes.appendIfPresent(upsertTurn( + id: incomingSnapshot.id, + state: incomingSnapshot.state, + itemsLoadState: incomingSnapshot.itemsLoadState, + preservesExistingUsage: true + )) + let snapshot = normalizingReviewRolloutCompanion( + incomingSnapshot, + hasPrecedingReviewExit: hasPrecedingReviewExit( + before: incomingSnapshot.id + ) + ) + if snapshot.itemsAreAuthoritative { + changes.append(contentsOf: removeItemsOmittedFromAuthoritativeSnapshot( + snapshot.items, + turnID: snapshot.id + )) + } + changes.append(contentsOf: mergeItems( + snapshot.items, + turnID: snapshot.id, + reviewCompanionEvidence: .snapshotBatch, + itemsLoadState: snapshot.itemsLoadState + )) + switch snapshot.state { + case .inProgress: + changes.appendIfPresent(markRunningIfNeeded(turnID: snapshot.id)) + case .completed: + changes.appendIfPresent(markIdleIfActive()) + phase = .terminal(turnID: snapshot.id, disposition: .completed) + case .interrupted: + changes.appendIfPresent(markIdleIfActive()) + phase = .terminal(turnID: snapshot.id, disposition: .interrupted) + case .failed: + changes.appendIfPresent(markIdleIfActive()) + phase = .terminal(turnID: snapshot.id, disposition: .failed) + case .unknown(let rawValue, _): + changes.appendIfPresent(markIdleIfActive()) + phase = .terminal( + turnID: snapshot.id, + disposition: .invalid(rawStatus: rawValue) + ) + } + case .terminal(let outcome): + changes.append(contentsOf: apply(outcome)) + changes.appendIfPresent(markIdleIfActive()) + case .itemStarted(let item, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + item, + turnID: turnID + )) + changes.append(contentsOf: mergeItems([ + itemByApplyingLifecycleStatus(.inProgress, to: item), + ], turnID: turnID, reviewCompanionEvidence: .orderedItems)) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .itemCompleted(let item, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + item, + turnID: turnID + )) + changes.append(contentsOf: mergeItems([ + itemByApplyingLifecycleStatus(.completed, to: item), + ], turnID: turnID, reviewCompanionEvidence: .orderedItems)) + case .itemUpdated(let item, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + item, + turnID: turnID + )) + changes.append(contentsOf: mergeItems( + [item], + turnID: turnID, + reviewCompanionEvidence: .orderedItems, + accumulatesOutputDeltas: isOutputDeltaUpdate(item) + )) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .message(let message, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + let item = CodexThreadItem( + id: message.id, + kind: message.role == .user ? .userMessage : .agentMessage, + content: .message(message) + ) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + item, + turnID: turnID + )) + changes.append(contentsOf: mergeItems( + [item], + turnID: turnID, + reviewCompanionEvidence: .orderedItems + )) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .messageDelta(let delta, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + incomingKey: CodexChatItemKey( + id: delta.itemID, + kind: .agentMessage, + turnID: turnID + ), + turnID: turnID + )) + changes.append(contentsOf: merge(delta, turnID: turnID)) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .reasoningSummaryPartAdded(let part, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + let item = CodexThreadItem(id: part.id, kind: .reasoning, content: .reasoning(.empty)) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + item, + turnID: turnID + )) + changes.append(contentsOf: start(part, turnID: turnID)) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .reasoningDelta(let delta, let turnID): + insertRunningTurnIfMissing(turnID, into: &changes) + changes.append(contentsOf: terminalizeActiveItemsBeforeAppending( + incomingKey: reasoningMergeKey(for: delta, turnID: turnID), + turnID: turnID + )) + changes.append(contentsOf: merge(delta, turnID: turnID)) + changes.appendIfPresent(markRunningIfNeeded(turnID: turnID)) + case .diagnostic: + break + case .tokenUsageUpdated(let usage, let turnID): + if let turnID { + changes.appendIfPresent(setUsage(usage, for: turnID)) + } + case .statusChanged(let status): + switch status { + case .active, .unknown: + changes.appendIfPresent(setStatus(status)) + changes.appendIfPresent(markRunningIfNeeded(turnID: nil)) + case .notLoaded, .idle, .systemError: + changes.appendIfPresent(setStatus(status)) + markInactiveWithoutTerminalizingTurn() + } + case .closed: + changes.appendIfPresent(setStatus(.notLoaded)) + markInactiveWithoutTerminalizingTurn() + case .unknown: + break + } + appendPhaseChange(to: &changes, previousPhase: previousPhase) + markAppliedLiveTurnItemUpdatesIfNeeded(changes) + return changes + } + + package var shouldPreserveTurnItemsWhenReconcilingSnapshot: Bool { + hasAppliedLiveTurnItemUpdates + } + + private var shouldPreserveSeededReviewTurnItemsWhenReconcilingSnapshot: Bool { + guard let seededReviewTurnID, + hasAppliedLiveTurnItemUpdates, + let seededReviewTurnItems = itemsByTurnID[seededReviewTurnID], + seededReviewTurnItems.isEmpty == false + else { + return false + } + guard turnsByID[seededReviewTurnID]?.status?.isTerminal == true else { + return true + } + return seededReviewTurnItems.contains(where: \.isExitedReviewModeMarker) == false + } + + private func markAppliedLiveTurnItemUpdatesIfNeeded(_ changes: [CodexChatMutation]) { + guard changes.containsTurnItemMutation else { + return + } + hasAppliedLiveTurnItemUpdates = true + } + + private enum ReviewCompanionEvidence { + case snapshotBatch + case orderedItems + } + + @discardableResult + private func mergeItems( + _ incomingItems: [CodexThreadItem], + turnID: CodexTurnID?, + reviewCompanionEvidence: ReviewCompanionEvidence, + itemsLoadState: CodexTurnItemsLoadState = .full, + accumulatesOutputDeltas: Bool = false + ) -> [CodexChatMutation] { + guard incomingItems.isEmpty == false else { + return [] + } + var changes: [CodexChatMutation] = [] + for rawIncomingItem in incomingItems { + let incomingItem = normalizingReviewRolloutCompanion( + rawIncomingItem, + turnID: turnID, + evidence: reviewCompanionEvidence + ) + if incomingItem.kind == .reasoning && incomingItem.id.contains(":summary:") == false + && incomingItem.id.contains(":content:") == false + { + changes.append(contentsOf: removeReasoningParts( + parentItemID: incomingItem.id, + turnID: turnID + )) + } + let incomingKey = CodexChatItemKey( + threadItem: incomingItem, + turnID: turnID + ) + let indexedItem = itemsByMergeKey[incomingKey] + let replayItem = indexedItem == nil + ? commandReplayItem(matching: incomingItem, turnID: turnID) + : nil + let existingItem = indexedItem ?? replayItem + if let existing = existingItem + { + let previousItem = existing.threadItem + let previousMergeKey = existing.mergeKey + let previousTurnID = existing.turnID + let incomingItem = itemByPreservingExistingLifecycle( + from: incomingItem, + existing: previousItem + ) + let movesAcrossTurns = previousTurnID != turnID + if movesAcrossTurns { + let replacementItem: CodexThreadItem + if accumulatesOutputDeltas, + mergeOutputDelta(incomingItem, into: existing, key: previousMergeKey) + { + replacementItem = existing.threadItem + } else { + replacementItem = incomingItem + } + let replacement = replaceItemAcrossTurns( + existing, + with: replacementItem, + turnID: turnID, + itemsLoadState: itemsLoadState + ) + changes.append(.itemRemoved( + locator: .init( + item: previousItem, + turnID: requiredObservationTurnID(previousTurnID) + ), + modelID: existing.id + )) + changes.append(.itemInserted(id: replacement.id, turnID: replacement.turnID)) + continue + } + let updateChange: CodexChatMutation? + if accumulatesOutputDeltas, + mergeOutputDelta(incomingItem, into: existing, key: previousMergeKey) + { + updateChange = changeForUpdatedItem( + existing, + previousItem: previousItem + ) + } else { + if shouldPreserveExistingFullItem(existing, incomingLoadState: itemsLoadState) { + continue + } + existing.update( + from: incomingItem, + itemsLoadState: itemsLoadState + ) + updateChange = changeForUpdatedItem( + existing, + previousItem: previousItem + ) + } + if existing.mergeKey != previousMergeKey { + migrateItemIdentity(existing, from: previousMergeKey) + rebuildItemIndexes() + changes.appendIfPresent(updateChange) + } else { + changes.appendIfPresent(updateChange) + } + } else { + if accumulatesOutputDeltas { + seedOutputDeltaStateIfNeeded(incomingItem, key: incomingKey) + } + let item = contextItem( + threadItem: incomingItem, + turnID: turnID, + itemsLoadState: itemsLoadState + ) + appendItem(item) + changes.append(.itemInserted(id: item.id, turnID: item.turnID)) + } + } + return changes + } + + private func normalizingReviewRolloutCompanion( + _ item: CodexThreadItem, + turnID: CodexTurnID?, + evidence: ReviewCompanionEvidence + ) -> CodexThreadItem { + guard item.kind == .agentMessage, + item.semanticRelation == nil, + let turnID + else { + return item + } + guard case .orderedItems = evidence else { + return item + } + let currentTurnItems = (itemsByTurnID[turnID] ?? []).map(\.threadItem) + let existingIndex = currentTurnItems.firstIndex { + $0.kind == item.kind && $0.id == item.id + } + let precedingItems = existingIndex.map { + currentTurnItems[..<$0] + } ?? currentTurnItems[...] + let precedingNarrativeItem = precedingItems.last { + $0.isReviewNarrativeBoundary + } + if precedingNarrativeItem?.isExitedReviewModeMarker == true { + return reviewRolloutCompanion(item) + } + + var candidateItems = currentTurnItems + if let existingIndex = candidateItems.firstIndex(where: { + $0.kind == item.kind && $0.id == item.id + }) { + candidateItems[existingIndex] = item + } else { + candidateItems.append(item) + } + let candidateRecord = CodexTurnSnapshot( + id: turnID, + state: turnsByID[turnID]?.state ?? .inProgress, + itemsLoadState: turnsByID[turnID]?.itemsLoadState ?? .notLoaded, + items: candidateItems + ) + if let agentIndex = sameTurnReviewCompanionAgentIndex(in: candidateRecord), + candidateItems[agentIndex].id == item.id, + candidateItems[agentIndex].kind == item.kind + { + return reviewRolloutCompanion(item) + } + guard persistedReviewCompanionAgentIndex(in: candidateRecord) != nil, + hasPrecedingReviewExit(before: turnID) + else { + return item + } + return reviewRolloutCompanion(item) + } + + private func normalizeReviewRolloutCompanion( + in turnID: CodexTurnID + ) -> [CodexChatMutation] { + guard let turn = turnsByID[turnID], + let state = turn.state, + let turnItems = itemsByTurnID[turnID] + else { + return [] + } + let record = CodexTurnSnapshot( + id: turnID, + state: state, + itemsLoadState: turn.itemsLoadState, + items: turnItems.map(\.threadItem) + ) + let agentIndex: Int + if let sameTurnAgentIndex = sameTurnReviewCompanionAgentIndex(in: record) { + agentIndex = sameTurnAgentIndex + } else if let persistedAgentIndex = persistedReviewCompanionAgentIndex(in: record), + hasPrecedingReviewExit(before: turnID) + { + agentIndex = persistedAgentIndex + } else { + return [] + } + guard turnItems[agentIndex].semanticRelation == nil else { + return [] + } + return mergeItems( + [reviewRolloutCompanion(turnItems[agentIndex].threadItem)], + turnID: turnID, + reviewCompanionEvidence: .snapshotBatch, + itemsLoadState: turnItems[agentIndex].itemsLoadState + ) + } + + private func hasPrecedingReviewExit( + before turnID: CodexTurnID, + excluding excludedTurnIDs: Set = [] + ) -> Bool { + let candidateIndex = turns.firstIndex(where: { $0.id == turnID }) + ?? turns.endIndex + guard candidateIndex > turns.startIndex else { + return false + } + for turn in turns[.. Bool { + existing.itemsLoadState == .full && incomingLoadState != .full + } + + private func commandReplayItem( + matching incomingItem: CodexThreadItem, + turnID: CodexTurnID? + ) -> CodexItem? { + guard let incomingCommand = incomingItem.command else { + return nil + } + let sameTurnCandidates: [CodexItem] + if let turnID { + sameTurnCandidates = itemsByTurnID[turnID] ?? [] + } else { + sameTurnCandidates = items.filter { $0.turnID == nil } + } + if let sameTurnReplay = sameTurnCandidates.first(where: { item in + guard let existingCommand = item.threadItem.command else { + return false + } + guard existingCommand.status?.isTerminal != true else { + return false + } + return commandsMatchForReplay(existingCommand, incomingCommand) + }) { + return sameTurnReplay + } + return items.first { item in + guard item.turnID != turnID else { + return false + } + guard let existingCommand = item.threadItem.command else { + return false + } + guard existingCommand.status?.isTerminal != true else { + return false + } + guard commandsMatchForReplay(existingCommand, incomingCommand) else { + return false + } + return commandsShareReplayIdentity( + existingItemID: item.itemID, + existingCommand: existingCommand, + incomingItemID: incomingItem.id, + incomingCommand: incomingCommand + ) + } + } + + private func commandsMatchForReplay( + _ existingCommand: CodexCommand, + _ incomingCommand: CodexCommand + ) -> Bool { + guard existingCommand.command == incomingCommand.command else { + return false + } + if let existingCWD = existingCommand.cwd, + let incomingCWD = incomingCommand.cwd, + existingCWD != incomingCWD + { + return false + } + if let existingProcessID = existingCommand.processID, + let incomingProcessID = incomingCommand.processID, + existingProcessID != incomingProcessID + { + return false + } + return existingCommand.source == incomingCommand.source + || existingCommand.source == nil + || incomingCommand.source == nil + } + + private func commandsShareReplayIdentity( + existingItemID: String, + existingCommand: CodexCommand, + incomingItemID: String, + incomingCommand: CodexCommand + ) -> Bool { + if existingItemID == incomingItemID { + return true + } + if let existingProcessID = existingCommand.processID, + let incomingProcessID = incomingCommand.processID, + existingProcessID == incomingProcessID + { + return true + } + return false + } + + private func itemByApplyingLifecycleStatus( + _ status: CodexTurnStatus, + to item: CodexThreadItem + ) -> CodexThreadItem { + let content: CodexThreadItem.Content + switch item.content { + case .command(var command): + if status.isTerminal { + command.status = lifecycleStatus(for: command, fallback: status) + } else { + command.status = command.status ?? lifecycleStatus(for: command, fallback: status) + } + content = .command(command) + case .fileChange(var fileChange): + fileChange.status = status.isTerminal ? status : fileChange.status ?? status + content = .fileChange(fileChange) + case .toolCall(var toolCall): + toolCall.status = status.isTerminal ? status : toolCall.status ?? status + content = .toolCall(toolCall) + default: + return item + } + return itemByReplacingContent(in: item, with: content) + } + + private func itemByPreservingExistingLifecycle( + from incomingItem: CodexThreadItem, + existing existingItem: CodexThreadItem + ) -> CodexThreadItem { + let content: CodexThreadItem.Content + switch (incomingItem.content, existingItem.content) { + case (.command(var incomingCommand), .command(let existingCommand)): + incomingCommand.status = mergedLifecycleStatus( + incoming: incomingCommand.status, + existing: existingCommand.status + ) + incomingCommand.startedAt = incomingCommand.startedAt ?? existingCommand.startedAt + incomingCommand.completedAt = incomingCommand.completedAt ?? existingCommand.completedAt + incomingCommand.duration = incomingCommand.duration ?? existingCommand.duration + incomingCommand.cwd = incomingCommand.cwd ?? existingCommand.cwd + incomingCommand.processID = incomingCommand.processID ?? existingCommand.processID + incomingCommand.source = incomingCommand.source ?? existingCommand.source + if incomingCommand.commandActions.isEmpty { + incomingCommand.commandActions = existingCommand.commandActions + } + content = .command(incomingCommand) + case (.fileChange(var incomingFileChange), .fileChange(let existingFileChange)): + incomingFileChange.status = mergedLifecycleStatus( + incoming: incomingFileChange.status, + existing: existingFileChange.status + ) + if isOutputDeltaUpdate(incomingItem) == false { + incomingFileChange.path = incomingFileChange.path ?? existingFileChange.path + incomingFileChange.output = incomingFileChange.output ?? existingFileChange.output + } + content = .fileChange(incomingFileChange) + case (.toolCall(var incomingToolCall), .toolCall(let existingToolCall)): + incomingToolCall.namespace = incomingToolCall.namespace ?? existingToolCall.namespace + incomingToolCall.server = incomingToolCall.server ?? existingToolCall.server + incomingToolCall.name = incomingToolCall.name ?? existingToolCall.name + incomingToolCall.arguments = incomingToolCall.arguments ?? existingToolCall.arguments + incomingToolCall.result = incomingToolCall.result ?? existingToolCall.result + incomingToolCall.error = incomingToolCall.error ?? existingToolCall.error + incomingToolCall.status = mergedLifecycleStatus( + incoming: incomingToolCall.status, + existing: existingToolCall.status + ) + content = .toolCall(incomingToolCall) + default: + return incomingItem + } + return itemByReplacingContent( + in: incomingItem, + with: content, + preservingSemanticMetadataFrom: existingItem + ) + } + + private func mergedLifecycleStatus( + incoming: CodexTurnStatus?, + existing: CodexTurnStatus? + ) -> CodexTurnStatus? { + guard let incoming else { + return existing + } + if existing?.isTerminal == true, incoming.isTerminal == false { + return existing + } + return incoming + } + + private func terminalizeActiveItems( + in turnID: CodexTurnID, + status: CodexTurnStatus + ) -> [CodexChatMutation] { + guard status.isTerminal else { + return [] + } + var changes: [CodexChatMutation] = [] + for item in itemsByTurnID[turnID] ?? [] { + let previousItem = item.threadItem + let terminalItem = itemByApplyingTerminalLifecycleStatus( + status, + to: previousItem + ) + guard terminalItem != previousItem else { + continue + } + item.update(from: terminalItem, itemsLoadState: item.itemsLoadState) + changes.appendIfPresent(changeForUpdatedItem(item, previousItem: previousItem)) + } + return changes + } + + private func itemByApplyingTerminalLifecycleStatus( + _ status: CodexTurnStatus, + to item: CodexThreadItem + ) -> CodexThreadItem { + let content: CodexThreadItem.Content + switch item.content { + case .command(var command): + guard shouldTerminalizeLifecycleStatus(command.status) else { + return item + } + command.status = lifecycleStatus(for: command, fallback: status) + content = .command(command) + case .fileChange(var fileChange): + guard shouldTerminalizeLifecycleStatus(fileChange.status) else { + return item + } + fileChange.status = status + content = .fileChange(fileChange) + case .toolCall(var toolCall): + guard shouldTerminalizeLifecycleStatus(toolCall.status) else { + return item + } + toolCall.status = status + content = .toolCall(toolCall) + default: + return item + } + return itemByReplacingContent(in: item, with: content) + } + + private func shouldTerminalizeLifecycleStatus(_ status: CodexTurnStatus?) -> Bool { + guard let status else { + return true + } + return status.isTerminal == false + } + + private func lifecycleStatus( + for command: CodexCommand, + fallback status: CodexTurnStatus + ) -> CodexTurnStatus { + guard status == .completed, let exitCode = command.exitCode else { + return status + } + return exitCode == 0 ? .completed : .failed + } + + private func itemByReplacingContent( + in item: CodexThreadItem, + with content: CodexThreadItem.Content, + preservingSemanticMetadataFrom metadataSource: CodexThreadItem? = nil + ) -> CodexThreadItem { + let metadataSource = metadataSource ?? item + return CodexThreadItem( + id: item.id, + kind: item.kind, + content: content, + origin: metadataSource.origin, + semanticRelation: metadataSource.semanticRelation, + rawPayload: item.rawPayload + ) + } + + private func seedOutputDeltaStateIfNeeded( + _ item: CodexThreadItem, + key: CodexChatItemKey + ) { + guard let delta = outputDeltaText(from: item) else { + return + } + liveMergeState.outputDeltaTextByItemKey[key] = delta + } + + @discardableResult + private func mergeOutputDelta( + _ incomingItem: CodexThreadItem, + into existing: CodexItem, + key: CodexChatItemKey + ) -> Bool { + guard existing.kind == incomingItem.kind, + let delta = outputDeltaText(from: incomingItem) + else { + return false + } + + let previousAccumulatedText = liveMergeState.outputDeltaTextByItemKey[key] ?? "" + let accumulatedText = previousAccumulatedText + delta + let merge = mergedDeltaText( + existingText: outputText(from: existing.threadItem), + previousAccumulatedText: previousAccumulatedText, + accumulatedText: accumulatedText, + deltaText: delta + ) + liveMergeState.outputDeltaTextByItemKey[key] = merge.accumulatedText + existing.update( + from: itemByReplacingOutput( + in: existing.threadItem, + with: merge.text, + using: incomingItem + ), + itemsLoadState: .full + ) + return true + } + + private func insertRunningTurnIfMissing( + _ turnID: CodexTurnID?, + into changes: inout [CodexChatMutation] + ) { + removeProvisionalSeedTurnIfNeeded(for: turnID, into: &changes) + guard let turnID, turnsByID[turnID] == nil else { + return + } + changes.appendIfPresent(upsertTurn( + id: turnID, + state: .inProgress, + preservesExistingUsage: true + )) + } + + private func isOutputDeltaUpdate(_ item: CodexThreadItem) -> Bool { + guard let rawPayload = item.rawPayload, + let payload = try? JSONDecoder().decode(ItemProgressPayload.self, from: rawPayload) + else { + return false + } + return payload.delta != nil + } + + private func outputDeltaText(from item: CodexThreadItem) -> String? { + switch item.content { + case .command(let command) + where command.command.isEmpty && command.cwd == nil && command.exitCode == nil: + command.output + case .fileChange(let fileChange) + where fileChange.path == nil: + fileChange.output + case .toolCall(let toolCall) + where toolCall.namespace == nil && toolCall.server == nil && toolCall.name == nil + && toolCall.arguments == nil && toolCall.error == nil: + toolCall.result + default: + nil + } + } + + private func outputText(from item: CodexThreadItem) -> String? { + switch item.content { + case .command(let command): + command.output + case .fileChange(let fileChange): + fileChange.output + case .toolCall(let toolCall): + toolCall.result + default: + nil + } + } + + private func itemByReplacingOutput( + in existingItem: CodexThreadItem, + with output: String, + using incomingItem: CodexThreadItem + ) -> CodexThreadItem { + let content: CodexThreadItem.Content + switch existingItem.content { + case .command(var command): + if case .command(let incomingCommand) = incomingItem.content, + let status = incomingCommand.status + { + command.status = status + } + command.output = output + content = .command(command) + case .fileChange(var fileChange): + if case .fileChange(let incomingFileChange) = incomingItem.content, + let status = incomingFileChange.status + { + fileChange.status = status + } + fileChange.output = output + content = .fileChange(fileChange) + case .toolCall(var toolCall): + if case .toolCall(let incomingToolCall) = incomingItem.content, + let status = incomingToolCall.status + { + toolCall.status = status + } + toolCall.result = output + content = .toolCall(toolCall) + default: + content = existingItem.content + } + return CodexThreadItem( + id: existingItem.id, + kind: existingItem.kind, + content: content, + origin: existingItem.origin, + semanticRelation: existingItem.semanticRelation, + rawPayload: incomingItem.rawPayload ?? existingItem.rawPayload + ) + } + + private func merge(_ delta: CodexMessageDelta, turnID: CodexTurnID?) -> [CodexChatMutation] { + let key = CodexChatItemKey( + id: delta.itemID, + kind: .agentMessage, + turnID: turnID + ) + let itemID = key.id + let existingItem = item(for: key) + let previousAccumulatedText = liveMergeState.messageDeltaTextByItemKey[key] ?? "" + let accumulatedText = previousAccumulatedText + delta.text + + let existingMessage = existingItem?.message + let merge = mergedDeltaText( + existingText: existingMessage?.text, + previousAccumulatedText: previousAccumulatedText, + accumulatedText: accumulatedText, + deltaText: delta.text + ) + liveMergeState.messageDeltaTextByItemKey[key] = merge.accumulatedText + let message = CodexMessage( + id: itemID, + role: existingMessage?.role ?? .assistant, + phase: delta.phase ?? existingMessage?.phase, + text: merge.text + ) + let item = CodexThreadItem(id: itemID, kind: .agentMessage, content: .message(message)) + return mergeItems( + [item], + turnID: turnID, + reviewCompanionEvidence: .orderedItems + ) + } + + private func start(_ part: CodexReasoningPart, turnID: CodexTurnID?) -> [CodexChatMutation] { + let key = CodexChatItemKey(id: part.id, kind: .reasoning, turnID: turnID) + guard item(for: key) == nil else { + return [] + } + return mergeItems([ + .init(id: part.id, kind: .reasoning, content: .reasoning(.empty)), + ], turnID: turnID, reviewCompanionEvidence: .orderedItems) + } + + private func merge(_ delta: CodexReasoningDelta, turnID: CodexTurnID?) -> [CodexChatMutation] { + let key = reasoningMergeKey(for: delta, turnID: turnID) + if let currentItem = delta.currentItem { + return mergeItems( + [currentItem], + turnID: turnID, + reviewCompanionEvidence: .orderedItems + ) + } + let previousAccumulatedText = liveMergeState.reasoningDeltaTextByItemKey[key] ?? "" + let accumulatedText = previousAccumulatedText + delta.delta + + let existingReasoning = item(for: key)?.reasoning + let existingText: String? + switch delta.part.kind { + case .summary: + existingText = existingReasoning?.summary.joined(separator: "\n") + case .text: + existingText = existingReasoning?.content.joined(separator: "\n") + } + let merge = mergedDeltaText( + existingText: existingText, + previousAccumulatedText: previousAccumulatedText, + accumulatedText: accumulatedText, + deltaText: delta.delta + ) + liveMergeState.reasoningDeltaTextByItemKey[key] = merge.accumulatedText + let reasoning: CodexReasoning + switch delta.part.kind { + case .summary: + reasoning = .init(summary: merge.text) + case .text: + reasoning = .init(content: merge.text) + } + return mergeItems([ + .init(id: key.id, kind: .reasoning, content: .reasoning(reasoning)), + ], turnID: turnID, reviewCompanionEvidence: .orderedItems) + } + + private func reasoningMergeKey( + for delta: CodexReasoningDelta, + turnID: CodexTurnID? + ) -> CodexChatItemKey { + let parentKey = CodexChatItemKey( + id: delta.part.itemID, + kind: .reasoning, + turnID: turnID + ) + if item(for: parentKey)?.reasoning != nil { + return parentKey + } + return CodexChatItemKey(id: delta.id, kind: .reasoning, turnID: turnID) + } + + private func mergedDeltaText( + existingText: String?, + previousAccumulatedText: String, + accumulatedText: String, + deltaText: String + ) -> DeltaTextMerge { + guard let existingText, existingText.isEmpty == false else { + return .init(text: accumulatedText, accumulatedText: accumulatedText) + } + if existingText.hasPrefix(accumulatedText) { + return .init(text: existingText, accumulatedText: accumulatedText) + } + if existingText.hasSuffix(accumulatedText) { + return .init(text: existingText, accumulatedText: existingText) + } + if accumulatedText.hasPrefix(existingText) { + return .init(text: accumulatedText, accumulatedText: accumulatedText) + } + if previousAccumulatedText.isEmpty, + deltaText.isEmpty == false, + (existingText.hasPrefix(deltaText) || existingText.hasSuffix(deltaText)) + { + return .init(text: existingText, accumulatedText: existingText) + } + if previousAccumulatedText.isEmpty { + let mergedText = existingText + deltaText + return .init(text: mergedText, accumulatedText: mergedText) + } + if existingText.hasSuffix(previousAccumulatedText) { + let mergedText = existingText + deltaText + return .init(text: mergedText, accumulatedText: mergedText) + } + if existingText == previousAccumulatedText { + return .init(text: accumulatedText, accumulatedText: accumulatedText) + } + return .init(text: existingText + deltaText, accumulatedText: accumulatedText) + } + + private func changeForUpdatedItem( + _ item: CodexItem, + previousItem: CodexThreadItem + ) -> CodexChatMutation? { + let currentItem = item.threadItem + guard currentItem != previousItem else { + return nil + } + if let delta = appendedText( + from: previousItem, + to: currentItem + ) { + return .itemTextAppended( + id: item.id, + turnID: item.turnID, + delta: delta + ) + } + return .itemUpdated(id: item.id, turnID: item.turnID) + } + + private func appendedText( + from previousItem: CodexThreadItem, + to currentItem: CodexThreadItem + ) -> String? { + guard previousItem.id == currentItem.id, + previousItem.kind == currentItem.kind else { + return nil + } + switch (previousItem.content, currentItem.content) { + case (.message(let previous), .message(var current)): + current.text = previous.text + guard current == previous else { return nil } + case (.plan, .plan): + break + case (.reasoning(let previous), .reasoning(let current)): + guard reasoningHasOnlyAppendedText(from: previous, to: current) else { + return nil + } + case (.command(let previous), .command(var current)): + current.output = previous.output + guard current == previous else { return nil } + case (.fileChange(let previous), .fileChange(var current)): + current.output = previous.output + guard current == previous else { return nil } + case (.toolCall(let previous), .toolCall(var current)): + current.result = previous.result + guard current == previous else { return nil } + case (.contextCompaction, .contextCompaction), + (.diagnostic, .diagnostic), + (.log, .log): + break + case (.unknown(let previous), .unknown(var current)): + current.text = previous.text + guard current == previous else { return nil } + default: + return nil + } + return appendedText( + previousText: previousItem.text, + currentText: currentItem.text + ) + } + + private func reasoningHasOnlyAppendedText( + from previous: CodexReasoning, + to current: CodexReasoning + ) -> Bool { + if previous.summary.isEmpty == false || current.summary.isEmpty == false { + return previous.content == current.content + && fragmentsHaveOnlyAppendedText(from: previous.summary, to: current.summary) + } + return fragmentsHaveOnlyAppendedText(from: previous.content, to: current.content) + } + + private func fragmentsHaveOnlyAppendedText( + from previous: [String], + to current: [String] + ) -> Bool { + if previous.isEmpty { + return current.count == 1 && current[0].isEmpty == false + } + if current.count == previous.count + 1, + current.dropLast().elementsEqual(previous), + current.last?.isEmpty == false { + return true + } + guard previous.count == current.count, + previous.dropLast().elementsEqual(current.dropLast()), + let previousLast = previous.last, + let currentLast = current.last else { + return false + } + return currentLast.hasPrefix(previousLast) && currentLast.count > previousLast.count + } + + private func appendedText(previousText: String?, currentText: String?) -> String? { + guard let currentText else { + return nil + } + let previousText = previousText ?? "" + guard currentText.hasPrefix(previousText), currentText.count > previousText.count else { + return nil + } + return String(currentText.dropFirst(previousText.count)) + } + + private func setUsage(_ usage: CodexTokenUsage, for turnID: CodexTurnID) -> CodexChatMutation? { + if let turn = turnsByID[turnID] { + let previousUsage = turn.usage + turn.usage = usage + return turn.usage == previousUsage ? nil : .turnUpdated(id: turn.id) + } else { + let turn = contextTurn(id: turnID, usage: usage) + turns.append(turn) + turnsByID[turn.id] = turn + return .turnInserted(id: turn.id) + } + } + + private func item(for key: CodexChatItemKey) -> CodexItem? { + itemsByMergeKey[key] + } + + private func removeProvisionalSeedTurn(_ provisionalTurnID: CodexTurnID) -> [CodexChatMutation] { + provisionalSeedTurnID = nil + guard let provisionalTurn = turnsByID[provisionalTurnID] else { + return [] + } + + let removedItems = items.filter { $0.turnID == provisionalTurnID } + let removedChanges = removedItems.map { item in + CodexChatMutation.itemRemoved( + locator: .init( + item: item.threadItem, + turnID: requiredObservationTurnID(item.turnID) + ), + modelID: item.id + ) + } + let removedKeys = Set(removedItems.map(\.mergeKey)) + if removedKeys.isEmpty == false { + items.removeAll { item in + removedKeys.contains(item.mergeKey) + } + for item in removedItems { + removeItemFromIndexes(item) + } + unregisterItemsFromContext(removedItems) + } + + turns.removeAll { $0 === provisionalTurn } + turnsByID.removeValue(forKey: provisionalTurnID) + itemsByTurnID.removeValue(forKey: provisionalTurnID) + provisionalTurn.replaceContextItems([]) + return removedChanges + [.turnRemoved(id: provisionalTurnID)] + } + + @discardableResult + private func removeProvisionalSeedTurnIfNeeded( + for liveTurnID: CodexTurnID?, + into changes: inout [CodexChatMutation] + ) -> Bool { + guard let provisionalTurnID = provisionalSeedTurnID, + let liveTurnID + else { + return false + } + guard provisionalTurnID != liveTurnID, + turnsByID[provisionalTurnID] != nil + else { + provisionalSeedTurnID = nil + return false + } + + changes.append(contentsOf: removeProvisionalSeedTurn(provisionalTurnID)) + changes.append(.turnUpdated(id: provisionalTurnID)) + return true + } + + private func terminalizeActiveItemsBeforeAppending( + _ incomingItem: CodexThreadItem, + turnID: CodexTurnID? + ) -> [CodexChatMutation] { + terminalizeActiveItemsBeforeAppending( + incomingKey: CodexChatItemKey(threadItem: incomingItem, turnID: turnID), + turnID: turnID + ) + } + + private func terminalizeActiveItemsBeforeAppending( + incomingKey: CodexChatItemKey, + turnID: CodexTurnID? + ) -> [CodexChatMutation] { + guard let turnID else { + return [] + } + if let existingItem = item(for: incomingKey), + isLifecycleTrackedItem(existingItem.threadItem) + { + return [] + } + var changes: [CodexChatMutation] = [] + for item in itemsByTurnID[turnID] ?? [] where item.mergeKey != incomingKey { + let previousItem = item.threadItem + let terminalItem = itemByApplyingTerminalLifecycleStatus( + .completed, + to: previousItem + ) + guard terminalItem != previousItem else { + continue + } + item.update(from: terminalItem, itemsLoadState: item.itemsLoadState) + changes.appendIfPresent(changeForUpdatedItem(item, previousItem: previousItem)) + } + return changes + } + + private func isLifecycleTrackedItem(_ item: CodexThreadItem) -> Bool { + switch item.content { + case .command, .fileChange, .toolCall: + true + default: + false + } + } + + private func hasActiveLifecycleStatus(_ item: CodexThreadItem) -> Bool { + switch item.content { + case .command(let command): + shouldTerminalizeLifecycleStatus(command.status) + case .fileChange(let fileChange): + shouldTerminalizeLifecycleStatus(fileChange.status) + case .toolCall(let toolCall): + shouldTerminalizeLifecycleStatus(toolCall.status) + default: + false + } + } + + private func removeReasoningParts( + parentItemID: String, + turnID: CodexTurnID? + ) -> [CodexChatMutation] { + let prefixes = ["\(parentItemID):summary:", "\(parentItemID):content:"] + let removedItems = items.filter { item in + item.turnID == turnID && prefixes.contains { item.itemID.hasPrefix($0) } + } + guard removedItems.isEmpty == false else { + return [] + } + let removedChanges = removedItems.map { item in + CodexChatMutation.itemRemoved( + locator: .init( + item: item.threadItem, + turnID: requiredObservationTurnID(item.turnID) + ), + modelID: item.id + ) + } + let removedKeys = Set(removedItems.map(\.mergeKey)) + items.removeAll { item in + removedKeys.contains(item.mergeKey) + } + for item in removedItems { + removeItemFromIndexes(item) + } + unregisterItemsFromContext(removedItems) + liveMergeState.reasoningDeltaTextByItemKey = liveMergeState.reasoningDeltaTextByItemKey + .filter { key, _ in + key.turnID != turnID || prefixes.contains { key.id.hasPrefix($0) } == false + } + return removedChanges + } + + @discardableResult + private func removeItemsOmittedFromAuthoritativeSnapshot( + _ incomingItems: [CodexThreadItem], + turnID: CodexTurnID, + preservesOmittedSeededReviewLogItems: Bool = false + ) -> [CodexChatMutation] { + var retainedItems = Set() + for incomingItem in incomingItems { + let incomingKey = CodexChatItemKey( + threadItem: incomingItem, + turnID: turnID + ) + if let item = item(for: incomingKey) { + retainedItems.insert(ObjectIdentifier(item)) + } + if let item = commandReplayItem(matching: incomingItem, turnID: turnID) { + retainedItems.insert(ObjectIdentifier(item)) + } + } + if preservesOmittedSeededReviewLogItems { + for item in itemsByTurnID[turnID] ?? [] where item.kind.isSeededReviewLogItem { + retainedItems.insert(ObjectIdentifier(item)) + } + } + let removedItems = items.filter { item in + item.turnID == turnID && retainedItems.contains(ObjectIdentifier(item)) == false + } + guard removedItems.isEmpty == false else { + return [] + } + let removedChanges = removedItems.map { item in + CodexChatMutation.itemRemoved( + locator: .init( + item: item.threadItem, + turnID: requiredObservationTurnID(item.turnID) + ), + modelID: item.id + ) + } + let removedKeys = Set(removedItems.map(\.mergeKey)) + items.removeAll { item in + removedKeys.contains(item.mergeKey) + } + for item in removedItems { + removeItemFromIndexes(item) + } + unregisterItemsFromContext(removedItems) + return removedChanges + } + + private func markRunningIfNeeded(turnID: CodexTurnID?) -> CodexChatMutation? { + let statusChange: CodexChatMutation? + if status?.isActive != true { + statusChange = setStatus(.active(activeFlags: [])) + } else { + statusChange = nil + } + if let turnID { + phase = .running(turnID: turnID) + } else if case .running = phase { + // A thread-scoped status update cannot replace known turn identity. + } else { + phase = .loading + } + return statusChange + } + + private func setStatus(_ status: CodexThreadStatus?) -> CodexChatMutation? { + let previousStatus = self.status + self.status = status + return previousStatus == status ? nil : .statusChanged(status) + } + + private func markInactiveWithoutTerminalizingTurn() { + switch phase { + case .idle, .loading: + phase = .idle + case .running, .terminal, .failed: + break + } + } + + private func markIdleIfActive() -> CodexChatMutation? { + guard status?.isActive == true else { + return nil + } + return setStatus(.idle) + } + + package func syncPhaseAfterRefresh(includeTurns: Bool) { + if includeTurns { + syncPhaseWithTurnsAfterRefresh() + } else { + syncPhaseWithStatusAfterMetadataRefresh() + } + } + + package func beginLoading() { + phase = .loading + } + + package func restorePhaseIfLoading(_ phase: CodexChatPhase) { + guard self.phase == .loading else { + return + } + self.phase = phase + } + + package func observationSnapshot() -> CodexThreadSnapshot { + precondition( + items.allSatisfy { $0.turnID != nil }, + "Current-v2 chat snapshots require every item to have a turn ID." + ) + return CodexThreadSnapshot( + id: id, + workspace: workspace?.url, + name: name, + preview: preview, + modelProvider: modelProvider, + sessionID: sessionID, + parentThreadID: parentThreadID, + source: source, + sourceKind: source == nil ? sourceKind : nil, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns.map(observationSnapshot(for:)) + ) + } + + package func observationUpdates( + for mutations: [CodexChatMutation] + ) -> [CodexChatUpdate] { + let replacingTurnIDs = Set(mutations.compactMap { mutation -> CodexTurnID? in + switch mutation { + case .turnInserted(let id), .turnRemoved(let id): + id + default: + nil + } + }) + let removedTurnIDs = Set(mutations.compactMap { mutation -> CodexTurnID? in + if case .turnRemoved(let id) = mutation { return id } + return nil + }) + var deferredTurnUpdateIDs: [CodexTurnID] = [] + var updates = mutations.compactMap { mutation -> CodexChatUpdate? in + switch mutation { + case .turnInserted(let id): + if removedTurnIDs.contains(id) { return nil } + return observationTurnUpdate(id: id, inserted: true) + case .turnUpdated(let id): + if removedTurnIDs.contains(id) { return nil } + if deferredTurnUpdateIDs.contains(id) == false { + deferredTurnUpdateIDs.append(id) + } + return nil + case .turnRemoved(let id): + return .turnRemoved(id: id) + case .itemInserted(let id, let turnID): + if turnID.map(replacingTurnIDs.contains) == true { return nil } + return observationItemUpdate(id: id, turnID: turnID, inserted: true) + case .itemUpdated(let id, let turnID): + if turnID.map(replacingTurnIDs.contains) == true { return nil } + return observationItemUpdate(id: id, turnID: turnID, inserted: false) + case .itemRemoved(let locator, _): + if replacingTurnIDs.contains(locator.turnID) { return nil } + return .itemRemoved(locator) + case .itemTextAppended(let id, let turnID, let delta): + if turnID.map(replacingTurnIDs.contains) == true { return nil } + let turnID = requiredObservationTurnID(turnID) + let (item, _) = observationItem(id: id, turnID: turnID) + return .itemTextAppended( + .init(item: item.threadItem, turnID: turnID), + delta: delta + ) + case .statusChanged(let status): + return .statusChanged(status) + case .phaseChanged(let phase): + return .phaseChanged(phase) + } + } + updates.append(contentsOf: deferredTurnUpdateIDs.map { + observationTurnUpdate(id: $0, inserted: false) + }) + return updates + } + + private func observationSnapshot(for turn: CodexTurn) -> CodexTurnSnapshot { + guard let state = turn.state else { + preconditionFailure("Observed turn \(turn.id.rawValue) has no state.") + } + return CodexTurnSnapshot( + id: turn.id, + state: state, + itemsLoadState: turn.itemsLoadState, + items: turn.items.map(\.threadItem) + ) + } + + private func observationTurnUpdate( + id: CodexTurnID, + inserted: Bool + ) -> CodexChatUpdate { + guard let index = turns.firstIndex(where: { $0.id == id }) else { + preconditionFailure("Observed turn mutation targets missing turn \(id.rawValue).") + } + let snapshot = observationSnapshot(for: turns[index]) + return inserted + ? .turnInserted(snapshot, index: index) + : .turnUpdated(snapshot, index: index) + } + + private func observationItemUpdate( + id: CodexChatItemID, + turnID: CodexTurnID?, + inserted: Bool + ) -> CodexChatUpdate { + let turnID = requiredObservationTurnID(turnID) + let (model, index) = observationItem(id: id, turnID: turnID) + let item = model.threadItem + return inserted + ? .itemInserted(item: item, turnID: turnID, index: index) + : .itemUpdated(item: item, turnID: turnID, index: index) + } + + private func observationItem( + id: CodexChatItemID, + turnID: CodexTurnID + ) -> (CodexItem, Int) { + let turnItems = items(in: turnID) + guard let index = turnItems.firstIndex(where: { $0.id == id }) else { + preconditionFailure("Observed item mutation targets missing item \(id).") + } + return (turnItems[index], index) + } + + private func requiredObservationTurnID(_ turnID: CodexTurnID?) -> CodexTurnID { + guard let turnID else { + preconditionFailure("Current-v2 observed item mutations require a turn ID.") + } + return turnID + } + + @discardableResult + package func syncPhaseWithTurnsAfterRefresh() -> CodexChatMutation? { + let previousPhase = phase + guard let latestTurn = turns.last else { + phase = status?.isActive == true ? .loading : .idle + return phase == previousPhase ? nil : .phaseChanged(phase) + } + switch latestTurn.state { + case .inProgress: + phase = .running(turnID: latestTurn.id) + case .completed: + phase = .terminal(turnID: latestTurn.id, disposition: .completed) + case .interrupted: + phase = .terminal(turnID: latestTurn.id, disposition: .interrupted) + case .failed: + phase = .terminal(turnID: latestTurn.id, disposition: .failed) + case .unknown(let rawValue, _): + phase = .terminal( + turnID: latestTurn.id, + disposition: .invalid(rawStatus: rawValue) + ) + case nil: + phase = status?.isActive == true ? .loading : .idle + } + return phase == previousPhase ? nil : .phaseChanged(phase) + } + + private func syncPhaseWithStatusAfterMetadataRefresh() { + switch status { + case .active: + if case .running = phase { + return + } + phase = .loading + case .notLoaded, .idle, .systemError, .unknown, .none: + switch phase { + case .terminal, .failed: + break + case .idle, .loading, .running: + phase = .idle + } + } + } + + private func appendPhaseChange( + to changes: inout [CodexChatMutation], + previousPhase: CodexChatPhase + ) { + if phase != previousPhase { + changes.append(.phaseChanged(phase)) + } + } + + package func fail(with error: any Error) { + if let failure = error as? CodexFetchFailure { + phase = .failed(failure) + return + } + if let appServerError = error as? CodexAppServerError { + phase = .failed(.appServer(appServerError)) + return + } + preconditionFailure("Unexpected CodexChat load failure: \(error)") + } + + package func resetLiveMergeStateFromCurrentItems() { + liveMergeState = LiveMergeState() + } + + private func rebuildTurnIndex() { + turnsByID = Dictionary( + turns.map { ($0.id, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + } + + private func rebuildItemIndexes() { + itemsByMergeKey.removeAll(keepingCapacity: true) + itemsByTurnID.removeAll(keepingCapacity: true) + for turn in turns { + turn.replaceContextItems([]) + } + var coalescedItems: [CodexItem] = [] + coalescedItems.reserveCapacity(items.count) + for item in items where itemsByMergeKey[item.mergeKey] == nil { + coalescedItems.append(item) + addItemToIndexes(item) + } + if coalescedItems.count != items.count { + items = coalescedItems + } + } + + private func appendItem(_ item: CodexItem) { + items.append(item) + addItemToIndexes(item) + } + + private func replaceItemAcrossTurns( + _ existing: CodexItem, + with incomingItem: CodexThreadItem, + turnID: CodexTurnID?, + itemsLoadState: CodexTurnItemsLoadState + ) -> CodexItem { + let replacementIndex = items.firstIndex { $0 === existing } + let previousMergeKey = existing.mergeKey + removeItemFromIndexes(existing) + unregisterItemsFromContext([existing]) + let replacement = contextItem( + threadItem: incomingItem, + turnID: turnID, + itemsLoadState: itemsLoadState + ) + if let replacementIndex { + items[replacementIndex] = replacement + } else { + items.append(replacement) + } + addItemToIndexes(replacement) + if let outputDeltaText = liveMergeState.outputDeltaTextByItemKey.removeValue( + forKey: previousMergeKey + ) { + liveMergeState.outputDeltaTextByItemKey[replacement.mergeKey] = outputDeltaText + } + return replacement + } + + // Single boundary for identity changes: whenever an existing item's merge + // key changes, live merge state and the context-level item registration + // must move together, or lagging references to the old key resurrect it. + private func migrateItemIdentity( + _ item: CodexItem, + from previousKey: CodexChatItemKey + ) { + migrateLiveMergeState(from: previousKey, to: item.mergeKey) + modelContext?.rekeyContextItem( + item, + from: previousKey.modelID(in: id), + to: item.mergeKey.modelID(in: id) + ) + } + + private func migrateLiveMergeState( + from oldKey: CodexChatItemKey, + to newKey: CodexChatItemKey + ) { + guard oldKey != newKey else { + return + } + if let messageText = liveMergeState.messageDeltaTextByItemKey.removeValue(forKey: oldKey) { + liveMergeState.messageDeltaTextByItemKey[newKey] = messageText + } + if let reasoningText = liveMergeState.reasoningDeltaTextByItemKey.removeValue( + forKey: oldKey + ) { + liveMergeState.reasoningDeltaTextByItemKey[newKey] = reasoningText + } + if let outputText = liveMergeState.outputDeltaTextByItemKey.removeValue(forKey: oldKey) { + liveMergeState.outputDeltaTextByItemKey[newKey] = outputText + } + } + + private func addItemToIndexes(_ item: CodexItem) { + itemsByMergeKey[item.mergeKey] = item + if let turnID = item.turnID { + itemsByTurnID[turnID, default: []].append(item) + turnsByID[turnID]?.attachContextItemIfNeeded(item) + } + } + + private func removeItemFromIndexes(_ item: CodexItem) { + itemsByMergeKey.removeValue(forKey: item.mergeKey) + guard let turnID = item.turnID else { + return + } + itemsByTurnID[turnID]?.removeAll { $0 === item } + turnsByID[turnID]?.detachContextItem(item) + if itemsByTurnID[turnID]?.isEmpty == true { + itemsByTurnID.removeValue(forKey: turnID) + } + } + + private func unregisterItemsFromContext(_ items: [CodexItem]) { + guard let modelContext else { + return + } + for item in items { + modelContext.unregisterContextItem(item) + item.detachFromContext() + } + } + + private struct LiveMergeState { + var messageDeltaTextByItemKey: [CodexChatItemKey: String] = [:] + var reasoningDeltaTextByItemKey: [CodexChatItemKey: String] = [:] + var outputDeltaTextByItemKey: [CodexChatItemKey: String] = [:] + } + + private struct DeltaTextMerge { + var text: String + var accumulatedText: String + } + + private struct ItemProgressPayload: Decodable { + var delta: String? + } + +} + +private extension CodexThreadItem.Kind { + var isSeededReviewLogItem: Bool { + switch self { + case .userMessage: + false + case .agentMessage, + .enteredReviewMode, + .exitedReviewMode, + .plan, + .reasoning, + .commandExecution, + .fileChange, + .mcpToolCall, + .dynamicToolCall, + .collabAgentToolCall, + .subAgentActivity, + .webSearch, + .imageView, + .sleep, + .imageGeneration, + .contextCompaction, + .diagnostic, + .error, + .unknown: + true + } + } +} + +@available( + *, + unavailable, + message: "Codex persistent models are not Sendable. Use the model ID to cross concurrency contexts." +) +extension CodexWorkspaceGroup: Sendable {} + +@available( + *, + unavailable, + message: "Codex persistent models are not Sendable. Use the model ID to cross concurrency contexts." +) +extension CodexWorkspace: Sendable {} + +@available( + *, + unavailable, + message: "Codex persistent models are not Sendable. Use the model ID to cross concurrency contexts." +) +extension CodexTurn: Sendable {} + +@available( + *, + unavailable, + message: "Codex persistent models are not Sendable. Use the model ID to cross concurrency contexts." +) +extension CodexItem: Sendable {} + +@available( + *, + unavailable, + message: "Codex persistent models are not Sendable. Use the model ID to cross concurrency contexts." +) +extension CodexChat: Sendable {} diff --git a/Sources/CodexDataKit/CodexModelActor.swift b/Sources/CodexDataKit/CodexModelActor.swift new file mode 100644 index 00000000..faf2f221 --- /dev/null +++ b/Sources/CodexDataKit/CodexModelActor.swift @@ -0,0 +1,48 @@ +import Dispatch + +public protocol CodexModelActor: Actor { + nonisolated var modelContainer: CodexModelContainer { get } + nonisolated var modelExecutor: CodexDefaultSerialModelExecutor { get } +} + +public extension CodexModelActor { + nonisolated var unownedExecutor: UnownedSerialExecutor { + modelExecutor.asUnownedSerialExecutor() + } + + var modelContext: CodexModelContext { + modelExecutor.modelContext + } +} + +// DispatchQueue serializes every job, and the context is package-only so it can +// only be reached through CodexModelActor's actor-isolated modelContext property. +public final class CodexDefaultSerialModelExecutor: @unchecked Sendable, SerialExecutor { + package let modelContext: CodexModelContext + + private let queue: DispatchQueue + + public convenience init(modelContainer: CodexModelContainer) { + self.init(modelContext: CodexModelContext(modelContainer)) + } + + package init(modelContext: CodexModelContext) { + self.modelContext = modelContext + self.queue = DispatchQueue( + label: "com.openai.codex-data-kit.model-executor", + qos: .userInitiated + ) + } + + public func enqueue(_ job: consuming ExecutorJob) { + let unownedJob = UnownedJob(job) + let executor = asUnownedSerialExecutor() + queue.async { + unownedJob.runSynchronously(on: executor) + } + } + + public func asUnownedSerialExecutor() -> UnownedSerialExecutor { + UnownedSerialExecutor(ordinary: self) + } +} diff --git a/Sources/CodexDataKit/CodexModelContext.swift b/Sources/CodexDataKit/CodexModelContext.swift new file mode 100644 index 00000000..bc65476d --- /dev/null +++ b/Sources/CodexDataKit/CodexModelContext.swift @@ -0,0 +1,3614 @@ +import AsyncAlgorithms +import CodexAppServerKit +import Foundation +import OSLog +import Synchronization + +private let logger = Logger(subsystem: "CodexDataKit", category: "model-context") + +public enum CodexModelContextError: Error, Equatable, Sendable { + case unsupportedModelType(String) + case modelIsDetached +} + +package struct CodexModelContextID: Hashable, Sendable { + private let rawValue: UUID + + package init() { + self.rawValue = UUID() + } +} + +package struct CodexStartedReviewContextChange: Sendable { + package var snapshot: CodexThreadSnapshot + package var eventThread: CodexThread + package var archived: Bool + package var provisionalSeedTurnID: CodexTurnID? + + package init( + snapshot: CodexThreadSnapshot, + eventThread: CodexThread, + archived: Bool, + provisionalSeedTurnID: CodexTurnID? + ) { + self.snapshot = snapshot + self.eventThread = eventThread + self.archived = archived + self.provisionalSeedTurnID = provisionalSeedTurnID + } +} + +package struct CodexModelContextTransaction: Sendable { + package var startedReviews: [CodexStartedReviewContextChange] = [] + + package init(startedReviews: [CodexStartedReviewContextChange] = []) { + self.startedReviews = startedReviews + } + + package var isEmpty: Bool { + startedReviews.isEmpty + } +} + +@MainActor +package final class CodexModelContextCoordinator { + package nonisolated let appServer: CodexAppServer + private weak var mainContext: CodexModelContext? + + package init(appServer: CodexAppServer) { + self.appServer = appServer + } + + package func attachMainContext(_ context: CodexModelContext) { + precondition(mainContext == nil, "A model coordinator can have one main context.") + precondition(context.appServer === appServer) + mainContext = context + } + + package func multicast( + _ transaction: CodexModelContextTransaction, + from sourceContextID: CodexModelContextID + ) async { + guard transaction.isEmpty == false, + let mainContext, + sourceContextID != mainContext.contextID + else { + return + } + await mainContext.merge(transaction) + } +} + +public final class CodexModelContainer: Equatable, Sendable { + public let appServer: CodexAppServer + package let coordinator: CodexModelContextCoordinator + + @MainActor + public let mainContext: CodexModelContext + + @MainActor + public init(appServer: CodexAppServer) { + let coordinator = CodexModelContextCoordinator(appServer: appServer) + let mainContext = CodexModelContext(coordinator: coordinator) + self.appServer = appServer + self.coordinator = coordinator + self.mainContext = mainContext + coordinator.attachMainContext(mainContext) + } + + public nonisolated static func == ( + lhs: CodexModelContainer, + rhs: CodexModelContainer + ) -> Bool { + lhs === rhs + } + +} + +public final class CodexModelContext: Equatable, SendableMetatype { + private static let localCursorPrefix = "codexkit-ui-offset:" + + private final class WeakActorReference: Sendable { + private final class Storage { + weak var value: (any Actor)? + + init(_ value: any Actor) { + self.value = value + } + } + + private let storage: Mutex + + init(_ value: any Actor) { + storage = Mutex(Storage(value)) + } + + func load() -> (any Actor)? { + storage.withLock { $0.value } + } + } + + private struct ChatFetchedResultState: Equatable { + var name: String? + var preview: String? + var modelProvider: String? + var sessionID: String? + var parentThreadID: CodexThreadID? + var sourceResolution: CodexThreadSourceResolution + var gitInfo: CodexThreadGitInfo? + var isArchived: Bool + var createdAt: Date? + var updatedAt: Date? + var recencyAt: Date? + var status: CodexThreadStatus? + var ephemeral: Bool? + var workspaceID: CodexWorkspaceID? + var workspaceGroupID: CodexWorkspaceGroupID? + } + + private struct FetchedThreadOccurrence: Sendable { + var snapshot: CodexThreadSnapshot + var sourceProvenance: CodexThreadListSourceProvenance + } + + private struct FetchedThreadCandidate: Sendable { + var firstOccurrence: FetchedThreadOccurrence + var additionalOccurrences: [FetchedThreadOccurrence] = [] + + init(snapshot: CodexThreadSnapshot, sourceKinds: [CodexThreadSourceKind]?) { + firstOccurrence = FetchedThreadOccurrence( + snapshot: snapshot, + sourceProvenance: CodexThreadListSourceProvenance(sourceKinds: sourceKinds) + ) + } + + var id: CodexThreadID { + firstOccurrence.snapshot.id + } + + var latestSnapshot: CodexThreadSnapshot { + additionalOccurrences.last?.snapshot ?? firstOccurrence.snapshot + } + + var hasMultipleOccurrences: Bool { + additionalOccurrences.isEmpty == false + } + + func sourceResolution( + startingAt initialResolution: CodexThreadSourceResolution + ) -> CodexThreadSourceResolution { + var resolution = initialResolution + resolution.apply( + firstOccurrence.snapshot, + partitionProvenance: firstOccurrence.sourceProvenance + ) + for occurrence in additionalOccurrences { + resolution.apply( + occurrence.snapshot, + partitionProvenance: occurrence.sourceProvenance + ) + } + return resolution + } + + mutating func append( + snapshot: CodexThreadSnapshot, + sourceKinds: [CodexThreadSourceKind]? + ) { + precondition( + snapshot.id == id, + "Only snapshots for the same thread can be combined." + ) + additionalOccurrences.append(FetchedThreadOccurrence( + snapshot: snapshot, + sourceProvenance: CodexThreadListSourceProvenance(sourceKinds: sourceKinds) + )) + } + } + + private struct RefreshedThreadSnapshot: Sendable { + var snapshot: CodexThreadSnapshot + var metadataReadCompleted: Bool + } + + private enum ChatObservationStartSnapshot: Sendable { + case loaded(RefreshedThreadSnapshot) + case failed(CodexAppServerError) + } + + private struct ChatObservationStartLoad: Sendable { + var thread: CodexThread + var source: String + var usesPreparedThread: Bool + var includesTurns: Bool + var eventStream: CodexThreadEventSequence? + var snapshot: ChatObservationStartSnapshot + } + + private enum ChatObservationStartOutcome: Sendable { + case loaded(ChatObservationStartLoad) + case failed(CodexAppServerError) + } + + private final class ActiveChatObservation { + let generation: UInt64 + let stablePhase: CodexChatPhase + let isolation: WeakActorReference + let releaseSignal = ChatObservationReleaseSignal() + var eventThread: CodexThread? + var eventStream: CodexThreadEventSequence? + var includesTurns = false + var isFinished = false + var isClosing = false + var finishSnapshotReason: CodexChatSnapshotReason? + var isBufferingEvents = false + var bufferedEvents: [CodexThreadEvent] = [] + var subscribers: [UUID: CodexChatObservationChannel] = [:] + var sequence: UInt64 = 0 + var hasAppliedLiveUpdates = false + var isStarting = true + var isCommittingStart = false + var startWasCancelled = false + var startWaiters: [ChatObservationStartWaiter] = [] + var pendingRegistrationCount = 0 + var startOperation: ChatObservationStartOperation? + var isUpgrading = false + var upgradeWaiters: [CheckedContinuation] = [] + var closeWaiters: [CheckedContinuation] = [] + + init( + generation: UInt64, + stablePhase: CodexChatPhase, + isolation: any Actor + ) { + self.generation = generation + self.stablePhase = stablePhase + self.isolation = WeakActorReference(isolation) + } + + func cancel() { + isFinished = true + startOperation?.cancel() + releaseSignal.terminate() + eventPump?.cancel() + discardBufferedEvents() + for channel in subscribers.values { + channel.finish() + } + subscribers.removeAll(keepingCapacity: false) + eventStream = nil + finishClosing() + } + + func waitUntilStarted() async throws { + if startWasCancelled { throw CancellationError() } + guard isStarting else { return } + let waiter = ChatObservationStartWaiter() + if startWasCancelled { + waiter.resolve(cancelled: true) + } else if isStarting { + startWaiters.append(waiter) + } else { + waiter.resolve(cancelled: false) + } + try await waiter.wait() + } + + func finishStarting(cancelled: Bool = false) { + precondition(isStarting) + isStarting = false + startWasCancelled = cancelled + let waiters = startWaiters + startWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resolve(cancelled: cancelled) + } + } + + func waitUntilUpgradeFinishes() async throws { + guard isUpgrading else { return } + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if isUpgrading { upgradeWaiters.append(continuation) } + else { continuation.resume() } + } + } + + func finishUpgrade(with error: (any Error)? = nil) { + precondition(isUpgrading) + isUpgrading = false + let waiters = upgradeWaiters + upgradeWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + if let error { waiter.resume(throwing: error) } + else { waiter.resume() } + } + } + + func waitUntilClosed() async { + guard isFinished == false else { return } + await withCheckedContinuation { continuation in + if isFinished { + continuation.resume() + } else { + closeWaiters.append(continuation) + } + } + } + + func finishClosing() { + isClosing = false + let waiters = closeWaiters + closeWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + var eventPump: ThreadEventPump? + + func beginBufferingEvents() { + isBufferingEvents = true + } + + func appendBufferedEvent(_ event: CodexThreadEvent) { + bufferedEvents.append(event) + } + + var hasBufferedEvents: Bool { + bufferedEvents.isEmpty == false + } + + var shouldPreserveLiveTurnItems: Bool { + hasAppliedLiveUpdates || hasBufferedEvents + } + + func markAppliedLiveUpdates() { + hasAppliedLiveUpdates = true + } + + func finishBufferingEvents() -> [CodexThreadEvent] { + isBufferingEvents = false + defer { + bufferedEvents.removeAll(keepingCapacity: true) + } + return bufferedEvents + } + + func discardBufferedEvents() { + isBufferingEvents = false + bufferedEvents.removeAll(keepingCapacity: true) + } + + func yield(_ mutations: [CodexChatMutation], chat: CodexChat) { + let updates = chat.observationUpdates(for: mutations) + guard updates.isEmpty == false else { + return + } + let firstSequence = sequence &+ 1 + sequence &+= UInt64(updates.count) + let events = updates.enumerated().map { offset, update in + CodexChatObservationEvent( + generation: generation, + sequence: firstSequence &+ UInt64(offset), + payload: .update(update) + ) + } + let overflow = snapshotEvent(chat: chat, reason: .bufferOverflow) + for channel in subscribers.values { + channel.yield(events, overflowSnapshot: overflow) + } + } + + func makeSubscriber(chat: CodexChat) -> (UUID, CodexChatUpdates) { + let id = UUID() + let channel = CodexChatObservationChannel( + releaseSignal: releaseSignal, + leaseID: id + ) + channel.seed(snapshotEvent( + chat: chat, + reason: finishSnapshotReason + ?? (generation == 1 ? .initial : .generationRestart) + )) + if isFinished { + channel.finish() + } else { + subscribers[id] = channel + } + return (id, CodexChatUpdates(channel: channel)) + } + + func removeSubscriber(_ id: UUID) { + subscribers.removeValue(forKey: id)?.finish() + } + + func broadcastSnapshot( + chat: CodexChat, + reason: CodexChatSnapshotReason, + advancesSequence: Bool = true + ) { + if advancesSequence { + sequence &+= 1 + } + let event = snapshotEvent(chat: chat, reason: reason) + for channel in subscribers.values { + channel.yield(event, overflowSnapshot: event) + } + } + + func finishSubscribers() { + for channel in subscribers.values { + channel.finish() + } + subscribers.removeAll(keepingCapacity: false) + } + + private func snapshotEvent( + chat: CodexChat, + reason: CodexChatSnapshotReason + ) -> CodexChatObservationEvent { + CodexChatObservationEvent( + generation: generation, + sequence: sequence, + payload: .snapshot( + .init(thread: chat.observationSnapshot(), phase: chat.phase), + reason: reason + ) + ) + } + } + + private final class ThreadEventPump { + private enum ChildResult: Sendable { + case upstreamFinished + case upstreamCancelled + case releaseReceiverFinished + case lastLeaseReleased(UUID) + } + + private let task: Task + private let releaseSignal: ChatObservationReleaseSignal + + init( + context: CodexModelContext, + chatID: CodexThreadID, + observation: ActiveChatObservation, + stream: CodexThreadEventSequence, + releaseSignal: ChatObservationReleaseSignal, + isolation: WeakActorReference + ) { + self.releaseSignal = releaseSignal + let target = ThreadEventPumpTarget( + context: context, + chatID: chatID, + observation: observation + ) + task = Task { + var lastReleasedLeaseID: UUID? + await withTaskGroup(of: ChildResult.self) { group in + group.addTask { + do { + for try await event in stream { + guard let actor = isolation.load() else { + return .upstreamCancelled + } + await target.process(event, isolation: actor) + } + if let actor = isolation.load() { + await target.finish(isolation: actor) + } + releaseSignal.terminate() + return .upstreamFinished + } catch is CancellationError { + return .upstreamCancelled + } catch { + if let actor = isolation.load() { + await target.fail(with: error, isolation: actor) + } + releaseSignal.terminate() + return .upstreamFinished + } + } + group.addTask { + while let release = await releaseSignal.next() { + guard let actor = isolation.load() else { + return .lastLeaseReleased(release.leaseID) + } + let isLast = await target.release( + leaseID: release.leaseID, + isolation: actor + ) + if isLast { + return .lastLeaseReleased(release.leaseID) + } + releaseSignal.acknowledge(release.leaseID) + } + return .releaseReceiverFinished + } + while let result = await group.next() { + switch result { + case .lastLeaseReleased(let leaseID): + lastReleasedLeaseID = leaseID + releaseSignal.terminate() + group.cancelAll() + case .upstreamFinished, .upstreamCancelled: + releaseSignal.terminate() + group.cancelAll() + case .releaseReceiverFinished: + if Task.isCancelled { + group.cancelAll() + } + } + } + } + if let lastReleasedLeaseID { + if let actor = isolation.load() { + await target.completeLastRelease(isolation: actor) + } + releaseSignal.acknowledge(lastReleasedLeaseID) + } + releaseSignal.completeAllAcknowledgements() + } + } + + func cancel() { + releaseSignal.terminate() + task.cancel() + } + + func cancelAndWait() async { + releaseSignal.terminate() + task.cancel() + await task.value + } + } + + // The raw event stream is consumed off-actor; model mutation hops to the + // isolation the observation was started from, which is the context owner's + // isolation (MainActor for the main context, the model executor for + // model-actor contexts). + private final class ThreadEventPumpTarget: @unchecked Sendable { + private weak var context: CodexModelContext? + private let chatID: CodexThreadID + private let observation: ActiveChatObservation + + init( + context: CodexModelContext, + chatID: CodexThreadID, + observation: ActiveChatObservation + ) { + self.context = context + self.chatID = chatID + self.observation = observation + } + + func process(_ event: CodexThreadEvent, isolation: isolated any Actor) async { + await context?.processObservedEvent(event, chatID: chatID, observation: observation) + } + + func finish(isolation: isolated any Actor) { + context?.finishChatObservationIfIdle(chatID, observation: observation) + } + + func fail(with error: Error, isolation: isolated any Actor) async { + await context?.failChatObservation(chatID, observation: observation, error: error) + } + + func release( + leaseID: UUID, + isolation: isolated any Actor + ) -> Bool { + guard let context else { return true } + return context.releaseChatObservationLease( + chatID, + observation: observation, + subscriberID: leaseID + ) + } + + func completeLastRelease(isolation: isolated any Actor) { + context?.completeChatObservationClose(chatID, observation: observation) + } + } + + private let coordinator: CodexModelContextCoordinator + package let appServer: CodexAppServer + package let contextID = CodexModelContextID() + + private var workspaceGroupsByID: [CodexWorkspaceGroupID: CodexWorkspaceGroup] = [:] + private var workspacesByID: [CodexWorkspaceID: CodexWorkspace] = [:] + private var chatsByID: [CodexThreadID: CodexChat] = [:] + private var turnsByID: [CodexTurnID: CodexTurn] = [:] + private var itemsByID: [CodexChatItemID: CodexItem] = [:] + private var fetchedResults: [WeakFetchedResultsRegistration] = [] + private var activeChatObservationsByID: [CodexThreadID: ActiveChatObservation] = [:] + private var chatObservationGenerationByID: [CodexThreadID: UInt64] = [:] + private var preparedEventThreadsByID: [CodexThreadID: CodexThread] = [:] + + public convenience init(_ container: CodexModelContainer) { + self.init(coordinator: container.coordinator) + } + + package init(coordinator: CodexModelContextCoordinator) { + self.coordinator = coordinator + self.appServer = coordinator.appServer + } + + public nonisolated static func == ( + lhs: CodexModelContext, + rhs: CodexModelContext + ) -> Bool { + lhs === rhs + } + + public nonisolated(nonsending) func fetch( + _ descriptor: CodexFetchDescriptor + ) async throws -> [Model] { + do { + try descriptor.validate() + let page = try await fetchPage(descriptor) + let items = fetchedItemsIncludingPendingChanges(from: page, descriptor: descriptor) + await syncLoadedRelationships(from: page, descriptor: descriptor, loadedItems: items) + return items + } catch is CancellationError { + throw CancellationError() + } catch let failure as CodexFetchValidationError { + throw CodexFetchFailure.validation(failure) + } catch let failure as CodexAppServerError { + throw CodexFetchFailure.appServer(failure) + } + } + + public func fetchedResults( + for descriptor: CodexFetchDescriptor, + sectionedBy sectionBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResults { + let results = CodexFetchedResults( + modelContext: self, + fetchDescriptor: descriptor, + sectionBy: sectionBy + ) + register(results) + return results + } + + public func model(for id: CodexThreadID) -> CodexChat { + chat(for: id) + } + + public func registeredModel(for id: CodexThreadID) -> CodexChat? { + chatsByID[id] + } + + public func registeredModel(for id: CodexWorkspaceID) -> CodexWorkspace? { + workspacesByID[id] + } + + public func registeredModel(for id: CodexWorkspaceGroupID) -> CodexWorkspaceGroup? { + workspaceGroupsByID[id] + } + + private func requireAttached(_ model: Model) throws { + guard model.modelContext === self else { + throw CodexModelContextError.modelIsDetached + } + let isRegistered: Bool + switch model { + case let chat as CodexChat: + isRegistered = chatsByID[chat.id] === chat + case let workspace as CodexWorkspace: + isRegistered = workspacesByID[workspace.id] === workspace + case let group as CodexWorkspaceGroup: + isRegistered = workspaceGroupsByID[group.id] === group + case let turn as CodexTurn: + isRegistered = turnsByID[turn.id] === turn + case let item as CodexItem: + isRegistered = itemsByID[item.id] === item + default: + throw CodexModelContextError.unsupportedModelType(String(describing: Model.self)) + } + guard isRegistered else { + throw CodexModelContextError.modelIsDetached + } + } + + package func turn( + id: CodexTurnID, + in chat: CodexChat, + state: CodexTurnSnapshot.State? = nil, + itemsLoadState: CodexTurnItemsLoadState? = nil, + usage: CodexTokenUsage? = nil + ) -> CodexTurn { + if let turn = turnsByID[id] { + turn.applyContextChat(chat) + return turn + } + let turn = CodexTurn( + id: id, + chat: chat, + modelContext: self, + state: state, + itemsLoadState: itemsLoadState, + usage: usage + ) + turnsByID[id] = turn + return turn + } + + package func item( + threadItem: CodexThreadItem, + turnID: CodexTurnID?, + in chat: CodexChat, + itemsLoadState: CodexTurnItemsLoadState + ) -> CodexItem { + let itemTurn: CodexTurn? + if let turnID { + itemTurn = turn(id: turnID, in: chat) + } else { + itemTurn = nil + } + let id = CodexChatItemKey(threadItem: threadItem, turnID: turnID).modelID(in: chat.id) + if let item = itemsByID[id] { + item.applyContextOwners(chat: chat, turn: itemTurn) + return item + } + let item = CodexItem( + threadItem: threadItem, + chat: chat, + turn: itemTurn, + modelContext: self, + itemsLoadState: itemsLoadState + ) + itemsByID[id] = item + return item + } + + package func unregisterContextItem(_ item: CodexItem) { + itemsByID = itemsByID.filter { $0.value !== item } + } + + package func rekeyContextItem( + _ item: CodexItem, + from oldID: CodexChatItemID, + to newID: CodexChatItemID + ) { + if itemsByID[oldID] === item { + itemsByID.removeValue(forKey: oldID) + } + itemsByID[newID] = item + } + + public nonisolated(nonsending) func refresh(_ group: CodexWorkspaceGroup) async throws { + try requireAttached(group) + + let descriptor = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + ) + let previousWorkspaces = group.workspaces + let previousChats = group.workspaces.flatMap(\.chats) + let fetchedChats = defaultUserVisibleChats(from: try await fetchAndApplyAllThreadSnapshots( + matching: descriptor, + appliedArchived: archivedScope(for: descriptor) == true, + scopedWorkspaceURL: singleWorkspaceScope(for: descriptor) + )) + let fetchedChatIDs = Set(fetchedChats.map(\.id)) + let chats = fetchedChats.filter { $0.workspace?.workspaceGroup?.id == group.id } + let workspaces = unique(chats.compactMap(\.workspace)) + for workspace in unique(previousWorkspaces + workspaces) { + let previousWorkspaceChats = workspace.chats + let fetchedWorkspaceChats = fetchedChats.filter { $0.workspace === workspace } + let fetchedIDs = Set(fetchedWorkspaceChats.map(\.id)) + let preservedChats = previousWorkspaceChats.filter { + fetchedIDs.contains($0.id) == false + && shouldPreserveMissingRefreshChat($0, archivedScope: archivedScope(for: descriptor)) + } + let currentChats = fetchedWorkspaceChats + preservedChats + workspace.replaceContextChats(currentChats) + pruneWorkspaceIfEmpty(workspace) + _ = detachStaleChats( + previousWorkspaceChats, + from: workspace, + keeping: currentChats, + archivedScope: archivedScope(for: descriptor) + ) + } + let previousWorkspacesStillInGroup = previousWorkspaces.filter { + $0.workspaceGroup?.id == group.id + } + let refreshedWorkspaces = workspaces.filter { $0.workspaceGroup?.id == group.id } + let refreshedWorkspaceIDs = Set(refreshedWorkspaces.map(\.id)) + let preservedWorkspaces = previousWorkspacesStillInGroup.filter { + refreshedWorkspaceIDs.contains($0.id) == false + && containsPreservedMissingRefreshChat(in: $0, archivedScope: archivedScope(for: descriptor)) + } + group.replaceContextWorkspaces(sort(refreshedWorkspaces + preservedWorkspaces, using: descriptor.sortBy)) + let currentChatIDs = Set(group.workspaces.flatMap(\.chats).map(\.id)) + let removedChats = previousChats.filter { + currentChatIDs.contains($0.id) == false + && fetchedChatIDs.contains($0.id) == false + && isInRefreshedScope($0, archivedScope: archivedScope(for: descriptor)) + } + await refreshWorkspaceGroupInRegisteredResults( + group, + archived: archivedScope(for: descriptor) == true, + removedChats: removedChats + ) + } + + public nonisolated(nonsending) func refresh(_ workspace: CodexWorkspace) async throws { + try requireAttached(workspace) + + let descriptor = CodexFetchDescriptor.chats(in: workspace) + let previousChats = workspace.chats + let fetchedChats = defaultUserVisibleChats(from: try await fetchAndApplyAllThreadSnapshots( + matching: descriptor, + appliedArchived: archivedScope(for: descriptor) == true, + scopedWorkspaceURL: singleWorkspaceScope(for: descriptor) + )) + let chats = sort( + fetchedChats, + using: descriptor.sortBy + ) + let refreshedIDs = Set(chats.map(\.id)) + let preservedChats = previousChats.filter { + refreshedIDs.contains($0.id) == false + && shouldPreserveMissingRefreshChat($0, archivedScope: archivedScope(for: descriptor)) + } + let currentChats = chats + preservedChats + workspace.replaceContextChats(currentChats) + pruneWorkspaceIfEmpty(workspace) + let removedChats = detachStaleChats( + previousChats, + from: workspace, + keeping: currentChats, + archivedScope: archivedScope(for: descriptor) + ) + await refreshWorkspaceInRegisteredResults( + workspace, + archived: archivedScope(for: descriptor) == true, + removedChats: removedChats + ) + } + + public nonisolated(nonsending) func refresh( + _ chat: CodexChat, + includeTurns: Bool = true + ) async throws { + try requireAttached(chat) + + let stablePhase = chat.phase + chat.beginLoading() + do { + let thread = try await eventThread(for: chat) + try await refresh(chat, using: thread, includeTurns: includeTurns) + } catch is CancellationError { + chat.restorePhaseIfLoading(stablePhase) + throw CancellationError() + } catch { + chat.fail(with: error) + throw error + } + } + + private func refresh( + _ chat: CodexChat, + using thread: CodexThread, + includeTurns: Bool, + replaysBufferedEvents: Bool = true, + emitsResynchronization: Bool = true + ) async throws { + let observation = activeChatObservationsByID[chat.id] + observation?.beginBufferingEvents() + let refreshedSnapshot: RefreshedThreadSnapshot + do { + refreshedSnapshot = try await Self.refreshedThreadSnapshot( + for: thread, + includeTurns: includeTurns + ) + } catch { + if replaysBufferedEvents { + await flushBufferedEvents(from: observation, to: chat) + } else { + observation?.discardBufferedEvents() + } + throw error + } + await applyRefreshedThreadSnapshot( + refreshedSnapshot, + to: chat, + includeTurns: includeTurns, + observation: observation, + replaysBufferedEvents: replaysBufferedEvents, + emitsResynchronization: emitsResynchronization + ) + } + + private func applyRefreshedThreadSnapshot( + _ refreshedSnapshot: RefreshedThreadSnapshot, + to chat: CodexChat, + includeTurns: Bool, + observation: ActiveChatObservation?, + replaysBufferedEvents: Bool, + emitsResynchronization: Bool + ) async { + let previousWorkspace = chat.workspace + let previousGroup = previousWorkspace?.workspaceGroup + let snapshot = refreshedSnapshot.snapshot + let snapshotCanLagBehindLiveEvents = snapshotCanLagBehindLiveEvents(refreshedSnapshot) + let chatShouldPreserveTurnItems = snapshotCanLagBehindLiveEvents + && chat.shouldPreserveTurnItemsWhenReconcilingSnapshot + let observationShouldPreserveTurnItems = snapshotCanLagBehindLiveEvents + && observation?.shouldPreserveLiveTurnItems == true + let preservesExistingTurnItems = replaysBufferedEvents + && (chatShouldPreserveTurnItems || observationShouldPreserveTurnItems) + if preservesExistingTurnItems { + logger.debug( + "Preserving live chat turn items during snapshot refresh chatID=\(chat.id.rawValue, privacy: .public) includeTurns=\(includeTurns, privacy: .public) chatHasLiveUpdates=\(chatShouldPreserveTurnItems, privacy: .public) observationHasLiveUpdates=\(observationShouldPreserveTurnItems, privacy: .public)" + ) + } + let refreshedChat = apply( + snapshot, + preservesExistingTurnItems: preservesExistingTurnItems + ) + if includeTurns { + refreshedChat.resetLiveMergeStateFromCurrentItems() + } + refreshedChat.syncPhaseAfterRefresh(includeTurns: includeTurns) + if emitsResynchronization { + observation?.broadcastSnapshot(chat: refreshedChat, reason: .refresh) + } + if replaysBufferedEvents { + await flushBufferedEvents(from: observation, to: refreshedChat) + } else { + observation?.discardBufferedEvents() + } + await revalidateChatInRegisteredResults( + refreshedChat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: refreshedChat.isArchived + ) + } + + private func snapshotCanLagBehindLiveEvents(_ refreshedSnapshot: RefreshedThreadSnapshot) -> Bool { + guard refreshedSnapshot.metadataReadCompleted, + refreshedSnapshot.snapshot.hasField(.status), + let status = refreshedSnapshot.snapshot.status + else { + return true + } + switch status { + case .active, + .unknown: + return true + case .idle, + .notLoaded, + .systemError: + return false + } + } + + private static func refreshedThreadSnapshot( + for thread: CodexThread, + includeTurns: Bool + ) async throws -> RefreshedThreadSnapshot { + guard includeTurns else { + return .init( + snapshot: try await thread.read(includeTurns: false), + metadataReadCompleted: true + ) + } + + do { + let turns = try await fullTurnList(for: thread) + return try await threadSnapshot( + for: thread, + withAuthoritativeTurns: turns + ) + } catch { + return .init( + snapshot: try await thread.read(includeTurns: true), + metadataReadCompleted: true + ) + } + } + + private static func fullTurnList(for thread: CodexThread) async throws -> [CodexTurnSnapshot] { + var cursor: String? + var turns: [CodexTurnSnapshot] = [] + repeat { + let page = try await thread.listTurns(.init( + cursor: cursor, + sortDirection: .ascending, + itemsLoadState: .full + )) + turns.append(contentsOf: page.turns) + cursor = page.nextCursor + } while cursor != nil + return turns + } + + private static func threadSnapshot( + for thread: CodexThread, + withAuthoritativeTurns turns: [CodexTurnSnapshot] + ) async throws -> RefreshedThreadSnapshot { + do { + let metadata = try await thread.read(includeTurns: false) + var presentFields = metadata.presentFields + presentFields.insert(.turns) + return .init( + snapshot: .init( + id: metadata.id, + workspace: metadata.workspace, + name: metadata.name, + preview: metadata.preview, + modelProvider: metadata.modelProvider, + sessionID: metadata.sessionID, + parentThreadID: metadata.parentThreadID, + source: metadata.source, + sourceKind: metadata.source == nil ? metadata.sourceKind : nil, + gitInfo: metadata.gitInfo, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + recencyAt: metadata.recencyAt, + status: metadata.status, + ephemeral: metadata.ephemeral, + turns: turns, + turnItemsAreAuthoritative: true, + presentFields: presentFields + ), + metadataReadCompleted: true + ) + } catch { + if Self.isThreadNotLoadedError(error) { + var presentFields: Set = [.status, .turns] + if thread.workspace != nil { + presentFields.insert(.workspace) + } + return .init( + snapshot: .init( + id: thread.id, + workspace: thread.workspace, + status: .notLoaded, + turns: turns, + turnItemsAreAuthoritative: true, + presentFields: presentFields + ), + metadataReadCompleted: false + ) + } + var presentFields: Set = [.turns] + if thread.workspace != nil { + presentFields.insert(.workspace) + } + return .init( + snapshot: .init( + id: thread.id, + workspace: thread.workspace, + turns: turns, + turnItemsAreAuthoritative: true, + presentFields: presentFields + ), + metadataReadCompleted: false + ) + } + } + + private static func isThreadNotLoadedError(_ error: Error) -> Bool { + let message: String + if case JSONRPC.Error.responseError(let serverError) = error { + message = serverError.message + } else if case CodexAppServerError.request(let failure) = error, + case .server(let serverError) = failure.kind { + message = serverError.message + } else { + return false + } + return message.lowercased().contains("thread not loaded") + } + + private func flushBufferedEvents( + from observation: ActiveChatObservation?, + to chat: CodexChat + ) async { + let bufferedEvents = observation?.finishBufferingEvents() ?? [] + for event in bufferedEvents { + _ = await apply(event, to: chat) + } + } + + private func yield( + _ updates: [CodexChatMutation], + from chat: CodexChat, + to observation: ActiveChatObservation? + ) { + guard let observation else { + return + } + observation.yield(updates, chat: chat) + } + + public func observe( + _ chat: CodexChat, + includeTurns: Bool = true, + isolation: isolated any Actor = #isolation + ) async throws -> CodexChatObservation { + try requireAttached(chat) + + let activeObservation = try await activeObservation( + for: chat, + includeTurns: includeTurns, + isolation: isolation + ) + + return makeChatObservation(chat: chat, activeObservation: activeObservation) + } + + private func activeObservation( + for chat: CodexChat, + includeTurns: Bool, + isolation: any Actor, + resumedThread: CodexThread? = nil + ) async throws -> ActiveChatObservation { + if let observation = activeChatObservationsByID[chat.id] { + precondition( + observation.isolation.load() === isolation, + "A chat observation generation must remain on its owner actor." + ) + if observation.isFinished { + activeChatObservationsByID.removeValue(forKey: chat.id) + } else if observation.isClosing { + await observation.waitUntilClosed() + return try await activeObservation( + for: chat, + includeTurns: includeTurns, + isolation: isolation, + resumedThread: resumedThread + ) + } else { + return try await joinObservation( + observation, + for: chat, + includeTurns: includeTurns + ) + } + } + + let chatID = chat.id + let generation = (chatObservationGenerationByID[chatID] ?? 0) &+ 1 + chatObservationGenerationByID[chatID] = generation + let stablePhase = chat.phase + chat.beginLoading() + let observation = ActiveChatObservation( + generation: generation, + stablePhase: stablePhase, + isolation: isolation + ) + activeChatObservationsByID[chatID] = observation + let suppliedThread: CodexThread? + let suppliedSource: String? + let usesPreparedThread: Bool + if let resumedThread { + suppliedThread = resumedThread + suppliedSource = "resumedThread" + usesPreparedThread = false + } else if let preparedThread = takePreparedEventThread(for: chatID) { + suppliedThread = preparedThread + suppliedSource = "preparedEventThread" + usesPreparedThread = true + } else { + suppliedThread = nil + suppliedSource = nil + usesPreparedThread = false + } + let appServer = appServer + observation.startOperation = ChatObservationStartOperation { + do { + let thread: CodexThread + let source: String + if let suppliedThread, let suppliedSource { + thread = suppliedThread + source = suppliedSource + } else { + thread = try await appServer.resumeThread(chatID) + source = "resumeThread" + } + try Task.checkCancellation() + let eventStream = usesPreparedThread + ? nil + : thread.makeCurrentGenerationEventStream() + let snapshot: ChatObservationStartSnapshot + do { + snapshot = .loaded(try await Self.refreshedThreadSnapshot( + for: thread, + includeTurns: includeTurns + )) + } catch is CancellationError { + throw CancellationError() + } catch let error as CodexAppServerError { + snapshot = .failed(error) + } + try Task.checkCancellation() + return .loaded(.init( + thread: thread, + source: source, + usesPreparedThread: usesPreparedThread, + includesTurns: includeTurns, + eventStream: eventStream, + snapshot: snapshot + )) + } catch is CancellationError { + throw CancellationError() + } catch let error as CodexAppServerError { + return .failed(error) + } + } + return try await joinObservation( + observation, + for: chat, + includeTurns: includeTurns + ) + } + + private func joinObservation( + _ observation: ActiveChatObservation, + for chat: CodexChat, + includeTurns: Bool + ) async throws -> ActiveChatObservation { + observation.pendingRegistrationCount += 1 + do { + guard let startOperation = observation.startOperation else { + preconditionFailure("A starting observation must own its start operation.") + } + let outcome = try await startOperation.value() + try await commitObservationStartIfNeeded( + outcome, + observation: observation, + chat: chat + ) + try Task.checkCancellation() + if observation.isFinished { + precondition(observation.pendingRegistrationCount > 0) + observation.pendingRegistrationCount -= 1 + return observation + } + if includeTurns, observation.includesTurns == false { + try await upgradeObservation(observation, for: chat) + } + try Task.checkCancellation() + precondition(observation.pendingRegistrationCount > 0) + observation.pendingRegistrationCount -= 1 + return observation + } catch { + precondition(observation.pendingRegistrationCount > 0) + observation.pendingRegistrationCount -= 1 + await cancelUnclaimedObservationIfNeeded( + chat.id, + observation: observation + ) + throw error + } + } + + private func commitObservationStartIfNeeded( + _ outcome: ChatObservationStartOutcome, + observation: ActiveChatObservation, + chat: CodexChat + ) async throws { + if observation.isStarting == false { + return + } + if observation.isCommittingStart { + try await observation.waitUntilStarted() + return + } + observation.isCommittingStart = true + defer { observation.isCommittingStart = false } + + switch outcome { + case .failed(let error): + finishObservationStartWithFailure( + error, + observation: observation, + chat: chat + ) + case .loaded(let load): + logger.debug( + "Starting chat observation chatID=\(chat.id.rawValue, privacy: .public) includeTurns=\(load.includesTurns, privacy: .public) source=\(load.source, privacy: .public) turns=\(chat.turns.count, privacy: .public) items=\(chat.items.count, privacy: .public)" + ) + observation.eventThread = load.thread + switch load.snapshot { + case .loaded(let refreshedSnapshot): + await applyRefreshedThreadSnapshot( + refreshedSnapshot, + to: chat, + includeTurns: load.includesTurns, + observation: observation, + replaysBufferedEvents: true, + emitsResynchronization: false + ) + case .failed(let error): + guard canObserveSeededSnapshotAfterInitialRefreshFailure( + chat, + usesPreparedThread: load.usesPreparedThread, + includeTurns: load.includesTurns + ) else { + finishObservationStartWithFailure( + error, + observation: observation, + chat: chat + ) + return + } + chat.syncPhaseAfterRefresh(includeTurns: load.includesTurns) + } + if load.usesPreparedThread, + shouldResetPreparedEventGenerationBeforeObserving( + chat, + includeTurns: load.includesTurns + ) { + load.thread.beginEventGeneration() + } + let eventStream = load.eventStream + ?? load.thread.makeCurrentGenerationEventStream() + startEventPump( + observation, + thread: load.thread, + eventStream: eventStream + ) + observation.includesTurns = load.includesTurns + observation.finishStarting() + } + } + + private func finishObservationStartWithFailure( + _ error: CodexAppServerError, + observation: ActiveChatObservation, + chat: CodexChat + ) { + chat.fail(with: error) + observation.finishSnapshotReason = .upstreamFailure + observation.isFinished = true + if observation.isStarting { + observation.finishStarting() + } + observation.releaseSignal.terminate() + activeChatObservationsByID.removeValue(forKey: chat.id) + } + + private func cancelUnclaimedObservationIfNeeded( + _ chatID: CodexThreadID, + observation: ActiveChatObservation + ) async { + guard observation.pendingRegistrationCount == 0, + observation.subscribers.isEmpty, + activeChatObservationsByID[chatID] === observation + else { + return + } + chat(for: chatID).restorePhaseIfLoading(observation.stablePhase) + await observation.startOperation?.cancelAndWait() + observation.releaseSignal.terminate() + await observation.eventPump?.cancelAndWait() + discardChatObservation(chatID, observation: observation) + } + + private func upgradeObservation( + _ observation: ActiveChatObservation, + for chat: CodexChat + ) async throws { + if observation.includesTurns { return } + if observation.isUpgrading { + try await observation.waitUntilUpgradeFinishes() + return + } + guard let thread = observation.eventThread else { + preconditionFailure("An active chat observation must own its event thread.") + } + observation.isUpgrading = true + do { + try await refresh( + chat, + using: thread, + includeTurns: true, + emitsResynchronization: false + ) + observation.includesTurns = true + observation.broadcastSnapshot(chat: chat, reason: .includeTurnsUpgrade) + observation.finishUpgrade() + } catch { + observation.finishUpgrade(with: error) + throw error + } + } + + private func startEventPump( + _ observation: ActiveChatObservation, + thread: CodexThread, + eventStream: CodexThreadEventSequence + ) { + observation.eventStream = eventStream + observation.eventPump = ThreadEventPump( + context: self, + chatID: thread.id, + observation: observation, + stream: eventStream, + releaseSignal: observation.releaseSignal, + isolation: observation.isolation + ) + } + + private func shouldResetPreparedEventGenerationBeforeObserving( + _ chat: CodexChat, + includeTurns: Bool + ) -> Bool { + guard includeTurns else { + return false + } + if chat.phase == .loading || chat.status?.isActive == true { + return false + } + if case .running = chat.phase { + return false + } + return true + } + + private func applyObservedEvent( + _ event: CodexThreadEvent, + to chat: CodexChat, + observation: ActiveChatObservation + ) async { + if observation.isBufferingEvents { + observation.appendBufferedEvent(event) + return + } + _ = await apply(event, to: chat) + } + + private func processObservedEvent( + _ event: CodexThreadEvent, + chatID: CodexThreadID, + observation: ActiveChatObservation + ) async { + guard observation.isFinished == false else { + return + } + guard let chat = registeredModel(for: chatID) else { + finishChatObservationIfIdle(chatID, observation: observation) + return + } + await applyObservedEvent(event, to: chat, observation: observation) + } + + private func failChatObservation( + _ chatID: CodexThreadID, + observation: ActiveChatObservation, + error: Error + ) async { + if let chat = registeredModel(for: chatID) { + chat.fail(with: error) + observation.finishSnapshotReason = .upstreamFailure + observation.broadcastSnapshot( + chat: chat, + reason: .upstreamFailure + ) + } + finishChatObservationIfIdle(chatID, observation: observation) + } + + private func canObserveSeededSnapshotAfterInitialRefreshFailure( + _ chat: CodexChat, + usesPreparedThread: Bool, + includeTurns: Bool + ) -> Bool { + includeTurns + && usesPreparedThread + && chat.turns.isEmpty == false + } + + private func releaseChatObservationLease( + _ chatID: CodexThreadID, + observation: ActiveChatObservation, + subscriberID: UUID + ) -> Bool { + guard activeChatObservationsByID[chatID] === observation else { + observation.removeSubscriber(subscriberID) + return observation.subscribers.isEmpty + } + observation.removeSubscriber(subscriberID) + if observation.subscribers.isEmpty == false { + return false + } + observation.isClosing = true + return true + } + + private func completeChatObservationClose( + _ chatID: CodexThreadID, + observation: ActiveChatObservation + ) { + observation.isFinished = true + observation.finishClosing() + if activeChatObservationsByID[chatID] === observation { + activeChatObservationsByID.removeValue(forKey: chatID) + } + } + + private func finishChatObservationIfIdle( + _ chatID: CodexThreadID, + observation: ActiveChatObservation + ) { + guard activeChatObservationsByID[chatID] === observation else { + return + } + observation.isFinished = true + observation.finishSubscribers() + observation.releaseSignal.terminate() + observation.finishClosing() + activeChatObservationsByID.removeValue(forKey: chatID) + } + + private func discardChatObservation( + _ chatID: CodexThreadID, + observation: ActiveChatObservation + ) { + guard activeChatObservationsByID[chatID] === observation else { + return + } + observation.cancel() + activeChatObservationsByID.removeValue(forKey: chatID) + } + + private func makeChatObservation( + chat: CodexChat, + activeObservation: ActiveChatObservation + ) -> CodexChatObservation { + let (subscriberID, updates) = activeObservation.makeSubscriber(chat: chat) + return CodexChatObservation( + chat: chat, + updates: updates, + leaseID: subscriberID, + modelContext: self, + releaseSignal: activeObservation.releaseSignal + ) + } + + private func prepareEventThread(_ thread: CodexThread, for chatID: CodexThreadID) { + if let observation = activeChatObservationsByID[chatID], + observation.isFinished == false + { + observation.eventThread = observation.eventThread ?? thread + preparedEventThreadsByID.removeValue(forKey: chatID) + } else { + preparedEventThreadsByID[chatID] = thread + } + } + + private func preparedEventThread(for chatID: CodexThreadID) -> CodexThread? { + preparedEventThreadsByID[chatID] + } + + private func takePreparedEventThread(for chatID: CodexThreadID) -> CodexThread? { + preparedEventThreadsByID.removeValue(forKey: chatID) + } + + private func eventThread(for chat: CodexChat) async throws -> CodexThread { + try requireAttached(chat) + if let thread = activeChatObservationsByID[chat.id]?.eventThread { + return thread + } + if let thread = takePreparedEventThread(for: chat.id) { + return thread + } + let thread = try await appServer.resumeThread(chat.id) + return thread + } + + @discardableResult + public nonisolated(nonsending) func startChat( + in workspace: CodexWorkspace, + input: CodexChatInput = .init() + ) async throws -> CodexChat { + try requireAttached(workspace) + let thread = try await appServer.startThread( + in: workspace.url, + instructions: input.instructions, + options: input.options + ) + let now = Date() + let snapshot = CodexThreadSnapshot( + id: thread.id, + workspace: thread.workspace, + modelProvider: input.options.modelProvider, + source: .appServer, + createdAt: now, + updatedAt: now, + ephemeral: input.options.ephemeral + ) + let chat = apply(snapshot) + chat.preserveSeededMetadataUntilAuthoritativeSnapshot() + chat.applyContextArchived(false) + prepareEventThread(thread, for: chat.id) + workspace.moveContextChatToFront(chat) + await insertChatIntoRegisteredResults(chat, archived: false) + return chat + } + + @discardableResult + public nonisolated(nonsending) func startReview( + in workspace: URL, + input: CodexReviewInput + ) async throws -> CodexStartedReview { + let review = try await appServer.startReview( + in: workspace, + target: input.target, + instructions: input.instructions, + options: input.options, + delivery: input.delivery + ) + return await applyStartedReview( + review, + workspaceURL: workspace, + input: input + ) + } + + @discardableResult + public nonisolated(nonsending) func startReview( + in workspace: CodexWorkspace, + input: CodexReviewInput + ) async throws -> CodexStartedReview { + try requireAttached(workspace) + return try await startReview(in: workspace.url, input: input) + } + + private func applyStartedReview( + _ review: CodexReviewSession, + workspaceURL: URL, + input: CodexReviewInput + ) async -> CodexStartedReview { + let eventThread = await appServer.reviewEventThread( + for: review, + workspace: workspaceURL + ) + let isExistingChat = chatsByID[review.activeTurnThreadID] != nil + let now = Date() + let change = CodexStartedReviewContextChange( + snapshot: CodexThreadSnapshot( + id: review.activeTurnThreadID, + workspace: eventThread.workspace ?? workspaceURL, + preview: input.target.dataKitPreview, + modelProvider: input.options.modelProvider, + source: .subAgent(.review), + createdAt: now, + updatedAt: now, + recencyAt: now, + status: .active(activeFlags: []), + ephemeral: input.options.ephemeral, + turns: [review.initialTurn], + turnItemsAreAuthoritative: false + ), + eventThread: eventThread, + archived: false, + provisionalSeedTurnID: review.initialTurn.id + ) + let chat = await applyStartedReview(change) + await coordinator.multicast( + CodexModelContextTransaction(startedReviews: [change]), + from: contextID + ) + logger.debug( + "Started review chat chatID=\(chat.id.rawValue, privacy: .public) reusedExistingChat=\(isExistingChat, privacy: .public) initialTurns=\(chat.turns.count, privacy: .public) initialItems=\(chat.items.count, privacy: .public)" + ) + return CodexStartedReview(chat: chat, session: review) + } + + @discardableResult + private func applyStartedReview(_ change: CodexStartedReviewContextChange) async -> CodexChat { + let chat = apply(change.snapshot) + chat.preserveSeededMetadataUntilAuthoritativeSnapshot() + if let provisionalSeedTurnID = change.provisionalSeedTurnID { + chat.markProvisionalSeedTurn(provisionalSeedTurnID) + } + chat.applyContextArchived(change.archived) + chat.syncPhaseAfterRefresh(includeTurns: change.snapshot.hasField(.turns)) + prepareEventThread(change.eventThread, for: chat.id) + chat.workspace?.moveContextChatToFront(chat) + await insertChatIntoRegisteredResults(chat, archived: change.archived) + return chat + } + + package func merge(_ transaction: CodexModelContextTransaction) async { + for change in transaction.startedReviews { + await applyStartedReview(change) + } + } + + @discardableResult + public nonisolated(nonsending) func send( + _ input: CodexChatMessageInput, + in chat: CodexChat + ) async throws -> CodexTurnOutcome { + try requireAttached(chat) + let thread = try await eventThread(for: chat) + switch try await thread.collectResponse(to: input.prompt, options: input.options) { + case .outcome(let outcome): + await apply(outcome, to: chat) + return outcome + case .cancelled(let outcome): + await apply(outcome, to: chat) + throw CancellationError() + } + } + + @discardableResult + package func apply(_ outcome: CodexTurnOutcome, to chat: CodexChat) async -> [CodexChatMutation] { + let previousWorkspace = chat.workspace + let previousGroup = previousWorkspace?.workspaceGroup + let previousUpdatedAt = chat.updatedAt + let changes = chat.apply(outcome) + if let workspace = chat.workspace, + let updatedAt = chat.updatedAt, + previousUpdatedAt.map({ updatedAt > $0 }) ?? true + { + workspace.moveContextChatToFront(chat) + } + let observation = activeChatObservationsByID[chat.id] + observation?.markAppliedLiveUpdates() + yield(changes, from: chat, to: observation) + await revalidateChatInRegisteredResults( + chat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: chat.isArchived + ) + return changes + } + + package func syncPhaseAfterSend(in chat: CodexChat) async { + guard let change = chat.syncPhaseWithTurnsAfterRefresh() else { + return + } + yield([change], from: chat, to: activeChatObservationsByID[chat.id]) + } + + @discardableResult + package func apply(_ event: CodexThreadEvent, to chat: CodexChat) async -> [CodexChatMutation] { + let previousWorkspace = chat.workspace + let previousGroup = previousWorkspace?.workspaceGroup + let previousState = fetchedResultState(for: chat) + let previousUpdatedAt = chat.updatedAt + let changes = chat.apply(event) + if let workspace = chat.workspace, + let updatedAt = chat.updatedAt, + previousUpdatedAt.map({ updatedAt > $0 }) ?? true + { + workspace.moveContextChatToFront(chat) + } + let observation = activeChatObservationsByID[chat.id] + observation?.markAppliedLiveUpdates() + yield(changes, from: chat, to: observation) + if previousState != fetchedResultState(for: chat) { + await revalidateChatInRegisteredResults( + chat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: chat.isArchived + ) + } + return changes + } + + public nonisolated(nonsending) func cancelActiveTurn(in chat: CodexChat) async throws { + try requireAttached(chat) + let thread = try await eventThread(for: chat) + _ = try await thread.cancelActiveTurn() + } + + public nonisolated(nonsending) func archive(_ chat: CodexChat) async throws { + try requireAttached(chat) + try await appServer.archiveThread(chat.id) + let workspace = chat.workspace + let group = workspace?.workspaceGroup + preparedEventThreadsByID.removeValue(forKey: chat.id) + if let workspace { + detach(chat, from: workspace) + } + chat.applyContextArchived(true) + await archiveChatInRegisteredResults(chat, workspace: workspace, group: group) + } + + public nonisolated(nonsending) func unarchive(_ chat: CodexChat) async throws { + try requireAttached(chat) + let previousWorkspace = chat.workspace + let previousGroup = previousWorkspace?.workspaceGroup + var snapshot = try await appServer.unarchiveThreadSnapshot(chat.id) + if snapshot.hasField(.workspace) == false, + let previousWorkspace + { + snapshot = snapshotForApply(snapshot, scopedWorkspaceURL: previousWorkspace.url) + } + let restoredChat = apply(snapshot, archived: false) + await revalidateChatInRegisteredResults( + restoredChat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: false + ) + } + + public nonisolated(nonsending) func delete(_ chat: CodexChat) async throws { + try requireAttached(chat) + try await appServer.deleteThread(chat.id) + await remove(chat) + } + + package func fetchPage( + _ descriptor: CodexFetchDescriptor, + cursor: String? = nil, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws -> CodexFetchPage { + try descriptor.validate() + if Model.self == CodexChat.self { + let page = try await fetchChatPage( + descriptor as! CodexFetchDescriptor, + cursor: cursor, + excluding: excludedRegistration + ) + return CodexFetchPage( + items: page.items.map { $0 as! Model }, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: page.relationshipItems?.map { $0 as! Model }, + relationshipIsComplete: page.relationshipIsComplete + ) + } + if Model.self == CodexWorkspace.self { + let page = try await fetchWorkspacePage( + descriptor as! CodexFetchDescriptor, + cursor: cursor, + excluding: excludedRegistration + ) + return CodexFetchPage( + items: page.items.map { $0 as! Model }, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: page.relationshipItems?.map { $0 as! Model }, + relationshipIsComplete: page.relationshipIsComplete + ) + } + if Model.self == CodexWorkspaceGroup.self { + let page = try await fetchWorkspaceGroupPage( + descriptor as! CodexFetchDescriptor, + cursor: cursor, + excluding: excludedRegistration + ) + return CodexFetchPage( + items: page.items.map { $0 as! Model }, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: page.relationshipItems?.map { $0 as! Model }, + relationshipIsComplete: page.relationshipIsComplete + ) + } + throw CodexModelContextError.unsupportedModelType(String(describing: Model.self)) + } + + package func sections( + for items: [Model], + sectionBy: CodexSectionDescriptor? + ) -> [CodexFetchSection] { + guard items.isEmpty == false else { + return [] + } + guard let sectionBy else { + return [CodexFetchSection(id: .default, title: nil, items: items)] + } + + var grouped: [(id: CodexFetchSectionID, title: String, items: [Model])] = [] + for item in items { + let section = sectionIdentity(for: item, descriptor: sectionBy) + if let index = grouped.firstIndex(where: { $0.id == section.id }) { + grouped[index].items.append(item) + } else { + grouped.append((id: section.id, title: section.title, items: [item])) + } + } + return grouped.map { + CodexFetchSection(id: $0.id, title: $0.title, items: $0.items) + } + } + + package func sortedItems( + _ items: [Model], + for descriptor: CodexFetchDescriptor + ) -> [Model] { + if Model.self == CodexChat.self { + let descriptor = descriptor as! CodexFetchDescriptor + return sortLocallyFetchedChats( + items as! [CodexChat], + using: descriptor + ).map { $0 as! Model } + } + if Model.self == CodexWorkspace.self { + let descriptor = descriptor as! CodexFetchDescriptor + return sort(items as! [CodexWorkspace], using: descriptor.sortBy).map { + $0 as! Model + } + } + if Model.self == CodexWorkspaceGroup.self { + let descriptor = descriptor as! CodexFetchDescriptor + return sort(items as! [CodexWorkspaceGroup], using: descriptor.sortBy).map { + $0 as! Model + } + } + return items + } + + package func fetchedItemsIncludingPendingChanges( + from page: CodexFetchPage, + descriptor: CodexFetchDescriptor, + existingItems: [Model] = [] + ) -> [Model] { + guard Model.self == CodexChat.self else { + return page.items + } + let preservedChats = preservedLiveChats( + omittedFrom: page.items, + descriptor: descriptor + ) + guard preservedChats.isEmpty == false else { + return page.items + } + logger.debug( + "Keeping live chats omitted from fetched page preservedCount=\(preservedChats.count, privacy: .public) pageCount=\(page.items.count, privacy: .public)" + ) + let chatDescriptor = descriptor as! CodexFetchDescriptor + let mergedChats = mergePreservedLiveChats( + preservedChats, + into: page.items as! [CodexChat], + existingChats: existingItems as? [CodexChat] ?? [], + descriptor: chatDescriptor + ) + return mergedChats.map { $0 as! Model } + } + + private func mergePreservedLiveChats( + _ preservedChats: [CodexChat], + into pageChats: [CodexChat], + existingChats: [CodexChat], + descriptor: CodexFetchDescriptor + ) -> [CodexChat] { + var result = pageChats + let existingIndexes = Dictionary( + uniqueKeysWithValues: existingChats.enumerated().map { ($0.element.id, $0.offset) } + ) + let orderedPreservedChats = preservedChats.sorted { lhs, rhs in + switch (existingIndexes[lhs.id], existingIndexes[rhs.id]) { + case (.some(let lhsIndex), .some(let rhsIndex)): + return lhsIndex < rhsIndex + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + if descriptor.sortBy.isEmpty { + return lhs.id.rawValue < rhs.id.rawValue + } + return liveChatShouldSortBefore(lhs, rhs, descriptor: descriptor) + } + } + for chat in orderedPreservedChats { + let insertionIndex = min( + existingIndexes[chat.id] + ?? liveChatInsertionIndex(for: chat, in: result, descriptor: descriptor), + result.count + ) + result.insert(chat, at: insertionIndex) + } + return sortLocallyFetchedChats(result, using: descriptor) + } + + private func liveChatInsertionIndex( + for chat: CodexChat, + in chats: [CodexChat], + descriptor: CodexFetchDescriptor + ) -> Int { + chats.firstIndex { existing in + liveChatShouldSortBefore(chat, existing, descriptor: descriptor) + } ?? chats.count + } + + private func liveChatShouldSortBefore( + _ lhs: CodexChat, + _ rhs: CodexChat, + descriptor: CodexFetchDescriptor + ) -> Bool { + guard descriptor.sortBy.isEmpty == false else { + return false + } + let plans = descriptor.sortBy.map(CodexSortPlan.afterValidation) + return shouldSortBefore( + lhs, + rhs, + using: plans, + stableID: { $0.id.rawValue }, + tieBreakOrder: plans[0].order + ) { plan, lhs, rhs in + plan.compare(lhs, rhs) + } + } + + package func backfillCursor(after itemCount: Int, currentCursor: String?) -> String? { + guard currentCursor?.hasPrefix(Self.localCursorPrefix) == true else { + return currentCursor + } + return localCursor(for: itemCount) + } + + private func fetchChatPage( + _ descriptor: CodexFetchDescriptor, + cursor: String?, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws + -> CodexFetchPage + { + let plan = try CodexThreadQueryPlan(descriptor: descriptor) + if canUseBoundedCompositeRecencyPage( + for: descriptor, + plan: plan, + cursor: cursor + ) { + if let page = try await fetchBoundedCompositeRecencyPage( + matching: descriptor, + plan: plan, + cursor: cursor, + excluding: excludedRegistration + ) { + return page + } + } + if canUseServerOrderedPages(for: descriptor, cursor: cursor) == false { + let fetchedChats = try await fetchAllChats( + matching: descriptor, + plan: plan, + excluding: excludedRegistration + ) + let chats = sortLocallyFetchedChats(fetchedChats, using: descriptor) + let page = localPage(chats, for: descriptor, cursor: cursor) + return CodexFetchPage( + items: page.items, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: chats, + relationshipIsComplete: true + ) + } + + let query = plan.threadQuery(cursor: cursor, includePaging: true) + let page = try await appServer.listThreads(query) + try Task.checkCancellation() + let fetchedChats = await applyFetchedSnapshots( + coalescedThreadCandidates(page.threads, sourceKinds: query.sourceKinds), + archived: plan.archived == true, + scopedWorkspaceURL: plan.singleWorkspace, + excluding: excludedRegistration + ).filter { plan.matchesServerResponse($0) } + let chats = sort( + fetchedChats, + using: descriptor.sortBy + ) + return CodexFetchPage( + items: chats, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor + ) + } + + private func coalescedThreadCandidates( + _ snapshots: [CodexThreadSnapshot], + sourceKinds: [CodexThreadSourceKind]? + ) -> [FetchedThreadCandidate] { + var candidates: [FetchedThreadCandidate] = [] + var indexesByID: [CodexThreadID: Int] = [:] + for snapshot in snapshots { + if let index = indexesByID[snapshot.id] { + candidates[index].append(snapshot: snapshot, sourceKinds: sourceKinds) + } else { + indexesByID[snapshot.id] = candidates.count + candidates.append(FetchedThreadCandidate( + snapshot: snapshot, + sourceKinds: sourceKinds + )) + } + } + return candidates + } + + private func fetchBoundedCompositeRecencyPage( + matching descriptor: CodexFetchDescriptor, + plan: CodexThreadQueryPlan, + cursor: String?, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? + ) async throws -> CodexFetchPage? { + guard let limit = descriptor.fetchLimit, limit > 0 else { + preconditionFailure("A bounded composite page requires a positive fetch limit.") + } + let requestedOffset = cursor == nil + ? descriptor.normalizedFetchOffset + : localCursorOffset(from: cursor) + let (candidateLimit, overflow) = requestedOffset.addingReportingOverflow(limit) + precondition(overflow == false, "The composite page offset and limit must fit in Int.") + + var candidates: [FetchedThreadCandidate] = [] + var indexesByID: [CodexThreadID: Int] = [:] + var hasUnfetchedCandidates = false + let baseQuery = plan.threadQuery(cursor: nil, includePaging: true) + for sourceKinds in plan.candidateSourceScope.sourceKindFilters { + var query = baseQuery + query.sourceKinds = sourceKinds + let partition = try await fetchBoundedThreadSnapshots( + query: query, + limit: candidateLimit + ) + for snapshot in partition.snapshots { + if let index = indexesByID[snapshot.id] { + candidates[index].append(snapshot: snapshot, sourceKinds: sourceKinds) + } else { + indexesByID[snapshot.id] = candidates.count + candidates.append(FetchedThreadCandidate( + snapshot: snapshot, + sourceKinds: sourceKinds + )) + } + } + hasUnfetchedCandidates = hasUnfetchedCandidates || partition.hasMore + } + + let requiresExhaustiveFallback = candidates.contains { candidate in + let initialResolution = chatsByID[candidate.id]?.threadSourceResolution ?? .unresolved + return candidate.hasMultipleOccurrences + || plan.candidateSourceScope.matches( + candidate.sourceResolution(startingAt: initialResolution) + ) == false + } + guard requiresExhaustiveFallback == false else { + return nil + } + + let sortOrder = plan.sortPlans[0].order + candidates.sort { lhs, rhs in + threadSnapshot( + lhs.latestSnapshot, + sortsBefore: rhs.latestSnapshot, + byRecencyIn: sortOrder + ) + } + + let start = min(requestedOffset, candidates.count) + let end = min(start + limit, candidates.count) + let pageCandidates = Array(candidates[start.. 0 ? localCursor(for: previousStart) : nil, + relationshipItems: relationshipIsComplete && start == 0 ? chats : nil, + relationshipIsComplete: relationshipIsComplete + ) + } + + private func fetchBoundedThreadSnapshots( + query baseQuery: CodexThreadQuery, + limit: Int + ) async throws -> (snapshots: [CodexThreadSnapshot], hasMore: Bool) { + var snapshots: [CodexThreadSnapshot] = [] + var cursor: String? + var seenCursors: Set = [] + + while snapshots.count < limit { + try Task.checkCancellation() + var query = baseQuery + query.cursor = cursor + query.limit = limit - snapshots.count + let page = try await appServer.listThreads(query) + let remainingCount = limit - snapshots.count + snapshots.append(contentsOf: page.threads.prefix(remainingCount)) + + if let nextCursor = page.nextCursor { + precondition( + nextCursor != cursor && seenCursors.insert(nextCursor).inserted, + "The app-server returned a repeated thread-list cursor." + ) + precondition( + page.threads.isEmpty == false, + "The app-server returned a non-advancing empty thread-list page." + ) + } + + if page.threads.count > remainingCount { + return (snapshots, true) + } + guard snapshots.count < limit else { + return (snapshots, page.nextCursor != nil) + } + guard let nextCursor = page.nextCursor else { + return (snapshots, false) + } + cursor = nextCursor + } + + return (snapshots, false) + } + + private func threadSnapshot( + _ lhs: CodexThreadSnapshot, + sortsBefore rhs: CodexThreadSnapshot, + byRecencyIn order: SortOrder + ) -> Bool { + switch (lhs.recencyAt, rhs.recencyAt) { + case (.some(let lhsDate), .some(let rhsDate)) where lhsDate != rhsDate: + return order == .forward ? lhsDate < rhsDate : lhsDate > rhsDate + case (.none, .some): + return order == .forward + case (.some, .none): + return order == .reverse + case (.some, .some), (.none, .none): + break + } + return order == .forward + ? lhs.id.rawValue < rhs.id.rawValue + : lhs.id.rawValue > rhs.id.rawValue + } + + private func fetchAllChats( + matching descriptor: CodexFetchDescriptor, + plan: CodexThreadQueryPlan, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws -> [CodexChat] { + var chats: [CodexChat] = [] + for archived in plan.archiveScopes { + let fetchedChats = try await fetchAndApplyAllThreadSnapshots( + matching: descriptor, + archiveScope: archived, + appliedArchived: archived, + scopedWorkspaceURL: plan.singleWorkspace, + excluding: excludedRegistration + ) + chats.append(contentsOf: fetchedChats.filter { plan.matchesServerResponse($0) }) + } + return unique(chats) + } + + private func fetchWorkspacePage( + _ descriptor: CodexFetchDescriptor, + cursor: String?, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws -> CodexFetchPage { + let chats = defaultUserVisibleChats(from: try await fetchAndApplyAllThreadSnapshots( + matching: descriptor, + appliedArchived: archivedScope(for: descriptor) == true, + scopedWorkspaceURL: singleWorkspaceScope(for: descriptor), + excluding: excludedRegistration + )) + let relationshipChats = chats + preservedLiveChatsForFetchedRelationships( + omittedFrom: chats, + descriptor: descriptor, + requiresIncludeContextChanges: true + ) + let removedChats = syncWorkspaceChats( + chats, + preservingExisting: shouldPreserveExistingWorkspaceChats( + for: descriptor, + relationshipIsComplete: true + ), + workspaceFilters: workspaceFilters(for: descriptor), + archivedScope: archivedScope(for: descriptor) + ) + await removeChatsFromRegisteredResults(removedChats, excluding: excludedRegistration) + let workspaces = unique(relationshipChats.compactMap(\.workspace)) + let sortedWorkspaces = sort(workspaces, using: descriptor.sortBy) + let page = localPage(sortedWorkspaces, for: descriptor, cursor: cursor) + return CodexFetchPage( + items: page.items, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: sortedWorkspaces, + relationshipIsComplete: true + ) + } + + private func fetchWorkspaceGroupPage( + _ descriptor: CodexFetchDescriptor, + cursor: String?, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws -> CodexFetchPage { + let chats = defaultUserVisibleChats(from: try await fetchAndApplyAllThreadSnapshots( + matching: descriptor, + appliedArchived: archivedScope(for: descriptor) == true, + scopedWorkspaceURL: singleWorkspaceScope(for: descriptor), + excluding: excludedRegistration + )) + let relationshipChats = chats + preservedLiveChatsForFetchedRelationships( + omittedFrom: chats, + descriptor: descriptor, + requiresIncludeContextChanges: true + ) + let preservingGroupWorkspaces = workspaceFilters(for: descriptor) != nil + || shouldPreserveExistingWorkspaceChats( + for: descriptor, + relationshipIsComplete: true + ) + let removedChats = syncWorkspaceChats( + chats, + preservingExisting: shouldPreserveExistingWorkspaceChats( + for: descriptor, + relationshipIsComplete: true + ), + workspaceFilters: workspaceFilters(for: descriptor), + archivedScope: archivedScope(for: descriptor) + ) + await removeChatsFromRegisteredResults(removedChats, excluding: excludedRegistration) + let workspaces = unique(relationshipChats.compactMap(\.workspace)) + syncGroupWorkspaces( + workspaces, + preservingExisting: preservingGroupWorkspaces, + archivedScope: archivedScope(for: descriptor) + ) + let groups = unique(workspaces.compactMap(\.workspaceGroup)) + let sortedGroups = sort(groups, using: descriptor.sortBy) + let page = localPage(sortedGroups, for: descriptor, cursor: cursor) + return CodexFetchPage( + items: page.items, + nextCursor: page.nextCursor, + backwardsCursor: page.backwardsCursor, + relationshipItems: sortedGroups, + relationshipIsComplete: true + ) + } + + private func applyFetchedSnapshots( + _ candidates: [FetchedThreadCandidate], + archived: Bool, + scopedWorkspaceURL: URL? = nil, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async -> [CodexChat] { + var revalidations: [CodexFetchedChatRevalidation] = [] + var appliedChats: [CodexChat] = [] + for candidate in candidates { + let existingChat = chatsByID[candidate.id] + let previousState = existingChat.map(fetchedResultState(for:)) + let previousWorkspace = existingChat?.workspace + let previousGroup = previousWorkspace?.workspaceGroup + let firstOccurrence = candidate.firstOccurrence + let firstSnapshot = snapshotForApply( + firstOccurrence.snapshot, + scopedWorkspaceURL: scopedWorkspaceURL + ) + var chat = apply( + firstSnapshot, + archived: archived, + sourceProvenance: firstOccurrence.sourceProvenance + ) + for occurrence in candidate.additionalOccurrences { + let snapshot = snapshotForApply( + occurrence.snapshot, + scopedWorkspaceURL: scopedWorkspaceURL + ) + chat = apply( + snapshot, + archived: archived, + sourceProvenance: occurrence.sourceProvenance + ) + } + if previousState == nil || previousState != fetchedResultState(for: chat) { + revalidations.append(CodexFetchedChatRevalidation( + chat: chat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: chat.isArchived + )) + } + appliedChats.append(chat) + } + await revalidateChatsInRegisteredResults(revalidations, excluding: excludedRegistration) + return appliedChats + } + + private func defaultUserVisibleChats(from chats: [CodexChat]) -> [CodexChat] { + chats.filter { + CodexThreadCandidateSourceScope.defaultUserVisible.matches( + CodexChatRecord(chat: $0) + ) + } + } + + private func snapshotForApply( + _ snapshot: CodexThreadSnapshot, + scopedWorkspaceURL: URL? + ) -> CodexThreadSnapshot { + guard let scopedWorkspaceURL, snapshot.hasField(.workspace) == false else { + return snapshot + } + var presentFields = snapshot.presentFields + presentFields.insert(.workspace) + return CodexThreadSnapshot( + id: snapshot.id, + workspace: scopedWorkspaceURL, + name: snapshot.name, + preview: snapshot.preview, + modelProvider: snapshot.modelProvider, + sessionID: snapshot.sessionID, + parentThreadID: snapshot.parentThreadID, + source: snapshot.source, + sourceKind: snapshot.source == nil ? snapshot.sourceKind : nil, + gitInfo: snapshot.gitInfo, + createdAt: snapshot.createdAt, + updatedAt: snapshot.updatedAt, + recencyAt: snapshot.recencyAt, + status: snapshot.status, + ephemeral: snapshot.ephemeral, + turns: snapshot.turns, + turnItemsAreAuthoritative: snapshot.turnItemsAreAuthoritative, + presentFields: presentFields + ) + } + + private func fetchedResultState(for chat: CodexChat) -> ChatFetchedResultState { + ChatFetchedResultState( + name: chat.name, + preview: chat.preview, + modelProvider: chat.modelProvider, + sessionID: chat.sessionID, + parentThreadID: chat.parentThreadID, + sourceResolution: chat.threadSourceResolution, + gitInfo: chat.gitInfo, + isArchived: chat.isArchived, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + recencyAt: chat.recencyAt, + status: chat.status, + ephemeral: chat.ephemeral, + workspaceID: chat.workspace?.id, + workspaceGroupID: chat.workspace?.workspaceGroup?.id + ) + } + + @discardableResult + private func apply( + _ snapshot: CodexThreadSnapshot, + archived: Bool? = nil, + sourceProvenance: CodexThreadListSourceProvenance? = nil, + preservesExistingTurnItems: Bool = false + ) -> CodexChat { + let chat = chat(for: snapshot.id) + let workspace: CodexWorkspace? + if snapshot.hasField(.workspace) { + workspace = snapshot.workspace.map(workspace(for:)) + if let previousWorkspace = chat.workspace { + let movedToDifferentWorkspace = workspace.map { $0 !== previousWorkspace } ?? true + if movedToDifferentWorkspace { + detach(chat, from: previousWorkspace) + } + } + } else { + workspace = chat.workspace + } + chat.apply( + snapshot, + workspace: workspace, + sourceProvenance: sourceProvenance, + preservesExistingTurnItems: preservesExistingTurnItems + ) + if let archived { + chat.applyContextArchived(archived) + } + workspace?.attachContextChatIfNeeded(chat) + return chat + } + + private func chat(for id: CodexThreadID) -> CodexChat { + if let chat = chatsByID[id] { + return chat + } + let chat = CodexChat(id: id, modelContext: self) + chatsByID[id] = chat + return chat + } + + private func workspace(for url: URL) -> CodexWorkspace { + let standardizedURL = Self.standardizedDirectoryURL(url) + let id = CodexWorkspaceID(rawValue: standardizedURL.path) + let groupIdentity = CodexWorkspaceGroupIdentity.identity(for: standardizedURL) + let group = workspaceGroup(for: groupIdentity) + let name = Self.displayName(for: standardizedURL) + let workspace: CodexWorkspace + if let existing = workspacesByID[id] { + workspace = existing + if let previousGroup = workspace.workspaceGroup, + previousGroup !== group + { + previousGroup.replaceContextWorkspaces(previousGroup.workspaces.filter { $0 !== workspace }) + } + workspace.applyContextSnapshot(url: standardizedURL, name: name, workspaceGroup: group) + } else { + workspace = CodexWorkspace( + id: id, + url: standardizedURL, + name: name, + workspaceGroup: group, + modelContext: self + ) + workspacesByID[id] = workspace + } + if group.workspaces.contains(where: { $0 === workspace }) == false { + group.replaceContextWorkspaces(sort( + group.workspaces + [workspace], + using: [CodexSortDescriptor(\.name)] + )) + } + return workspace + } + + private func workspaceGroup(for identity: CodexWorkspaceGroupIdentity) -> CodexWorkspaceGroup { + if let group = workspaceGroupsByID[identity.id] { + group.applyContextSnapshot(name: identity.title) + return group + } + let group = CodexWorkspaceGroup( + id: identity.id, + name: identity.title, + modelContext: self + ) + workspaceGroupsByID[identity.id] = group + return group + } + + private func remove(_ chat: CodexChat) async { + let workspace = chat.workspace + let group = workspace?.workspaceGroup + if let observation = activeChatObservationsByID[chat.id] { + discardChatObservation(chat.id, observation: observation) + } + preparedEventThreadsByID.removeValue(forKey: chat.id) + chatsByID.removeValue(forKey: chat.id) + turnsByID = turnsByID.filter { $0.value.chat !== chat } + itemsByID = itemsByID.filter { $0.value.chat !== chat } + if let workspace { + detach(chat, from: workspace) + } + chat.detachFromContext() + await removeChatFromRegisteredResults(chat, workspace: workspace, group: group) + } + + package func syncLoadedRelationships( + from page: CodexFetchPage, + descriptor: CodexFetchDescriptor, + loadedItems: [Model]? = nil, + cursor: String? = nil, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + var relationshipItems = page.relationshipItems ?? loadedItems ?? page.items + if Model.self == CodexChat.self { + let preserved = relationshipPreservedLiveChats( + omittedFrom: relationshipItems, + descriptor: descriptor + ) + if preserved.isEmpty == false { + relationshipItems.append(contentsOf: preserved.map { $0 as! Model }) + } + } + let relationshipIsComplete = page.relationshipIsComplete + ?? (page.nextCursor == nil && descriptor.normalizedFetchOffset == 0) + await syncLoadedRelationships( + relationshipItems, + descriptor: descriptor, + relationshipIsComplete: relationshipIsComplete, + excluding: excludedRegistration + ) + } + + private func syncLoadedRelationships( + _ items: [Model], + descriptor: CodexFetchDescriptor, + relationshipIsComplete: Bool, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + if let chats = items as? [CodexChat] { + let preservingExisting = shouldPreserveExistingWorkspaceChats( + for: descriptor, + relationshipIsComplete: relationshipIsComplete + ) + let removedChats = syncWorkspaceChats( + chats, + preservingExisting: preservingExisting, + workspaceFilters: workspaceFilters(for: descriptor), + archivedScope: archivedScope(for: descriptor) + ) + await removeChatsFromRegisteredResults(removedChats, excluding: excludedRegistration) + } + } + + private func syncWorkspaceChats( + _ chats: [CodexChat], + preservingExisting: Bool, + workspaceFilters: [URL]?, + archivedScope: Bool? + ) -> [(chat: CodexChat, workspace: CodexWorkspace, group: CodexWorkspaceGroup?)] { + var removedChats: [( + chat: CodexChat, + workspace: CodexWorkspace, + group: CodexWorkspaceGroup? + )] = [] + let fetchedWorkspaces = unique(chats.compactMap(\.workspace)) + let workspaces: [CodexWorkspace] + if preservingExisting { + workspaces = fetchedWorkspaces + } else if let workspaceFilters { + let filteredWorkspaces = workspaceFilters.compactMap(workspaceIfLoaded(for:)) + workspaces = unique(filteredWorkspaces + fetchedWorkspaces) + } else { + workspaces = Array(workspacesByID.values) + } + for workspace in workspaces { + let previousChats = workspace.chats + let fetchedChats = chats.filter { $0.workspace === workspace } + if preservingExisting { + let fetchedIDs = Set(fetchedChats.map(\.id)) + let remainingChats = workspace.chats.filter { fetchedIDs.contains($0.id) == false } + workspace.replaceContextChats(fetchedChats + remainingChats) + } else { + let fetchedIDs = Set(fetchedChats.map(\.id)) + let preservedChats = workspace.chats.filter { + fetchedIDs.contains($0.id) == false + && shouldPreserveMissingRefreshChat($0, archivedScope: archivedScope) + } + let currentChats = fetchedChats + preservedChats + workspace.replaceContextChats(currentChats) + let staleChats = detachStaleChats( + previousChats, + from: workspace, + keeping: currentChats, + archivedScope: archivedScope + ) + let group = workspace.workspaceGroup + if staleChats.isEmpty == false { + } + removedChats.append(contentsOf: staleChats.map { + (chat: $0, workspace: workspace, group: group) + }) + pruneWorkspaceIfEmpty(workspace) + } + } + return removedChats + } + + private func shouldPreserve(_ chat: CodexChat, outside archivedScope: Bool?) -> Bool { + switch archivedScope { + case .some(true): + chat.isArchived == false + case .some(false), .none: + chat.isArchived + } + } + + private func isInRefreshedScope(_ chat: CodexChat, archivedScope: Bool?) -> Bool { + switch archivedScope { + case .some(true): + chat.isArchived + case .some(false), .none: + chat.isArchived == false + } + } + + private func shouldPreserveMissingRefreshChat(_ chat: CodexChat, archivedScope: Bool?) -> Bool { + CodexThreadCandidateSourceScope.defaultUserVisible.matches(CodexChatRecord(chat: chat)) + && (shouldPreserve(chat, outside: archivedScope) || shouldPreserveLiveFetchedChat(chat)) + } + + private func containsPreservedMissingRefreshChat( + in workspace: CodexWorkspace, + archivedScope: Bool? + ) -> Bool { + workspace.chats.contains { + shouldPreserveMissingRefreshChat($0, archivedScope: archivedScope) + } + } + + private func syncGroupWorkspaces( + _ workspaces: [CodexWorkspace], + preservingExisting: Bool, + archivedScope: Bool? + ) { + let fetchedGroups = unique(workspaces.compactMap(\.workspaceGroup)) + let groups = preservingExisting ? fetchedGroups : Array(workspaceGroupsByID.values) + for group in groups { + let fetchedWorkspaces = workspaces.filter { $0.workspaceGroup === group } + if preservingExisting { + let fetchedIDs = Set(fetchedWorkspaces.map(\.id)) + let remainingWorkspaces = group.workspaces.filter { + fetchedIDs.contains($0.id) == false + } + group.replaceContextWorkspaces(sort( + fetchedWorkspaces + remainingWorkspaces, + using: [CodexSortDescriptor(\.name)] + )) + } else { + let fetchedIDs = Set(fetchedWorkspaces.map(\.id)) + let preservedWorkspaces = group.workspaces.filter { + fetchedIDs.contains($0.id) == false + && containsPreservedMissingRefreshChat(in: $0, archivedScope: archivedScope) + } + group.replaceContextWorkspaces(sort( + fetchedWorkspaces + preservedWorkspaces, + using: [CodexSortDescriptor(\.name)] + )) + } + } + } + + private func detach(_ chat: CodexChat, from workspace: CodexWorkspace) { + workspace.replaceContextChats(workspace.chats.filter { $0 !== chat }) + pruneWorkspaceIfEmpty(workspace) + } + + private func detachStaleChats( + _ previousChats: [CodexChat], + from workspace: CodexWorkspace, + keeping refreshedChats: [CodexChat], + archivedScope: Bool? + ) -> [CodexChat] { + let refreshedIDs = Set(refreshedChats.map(\.id)) + let staleChats = previousChats.filter { + refreshedIDs.contains($0.id) == false + && isInRefreshedScope($0, archivedScope: archivedScope) + && shouldPreserveLiveFetchedChat($0) == false + } + for chat in staleChats { + chat.detachFromWorkspace(workspace) + } + return staleChats + } + + private func pruneWorkspaceIfEmpty(_ workspace: CodexWorkspace) { + guard workspace.chats.isEmpty, let group = workspace.workspaceGroup else { + return + } + group.replaceContextWorkspaces(group.workspaces.filter { $0 !== workspace }) + } + + private func shouldPreserveExistingWorkspaceChats( + for descriptor: CodexFetchDescriptor, + relationshipIsComplete: Bool + ) -> Bool { + let plan = chatQueryPlan(for: descriptor) + return (Model.self == CodexChat.self + && relationshipIsComplete == false) + || plan?.membershipRequiresServerRefresh == true + } + + package func preservedLiveChats( + omittedFrom loadedItems: [Model], + descriptor: CodexFetchDescriptor + ) -> [CodexChat] { + preservedLiveChats( + omittedFrom: loadedItems, + descriptor: descriptor, + requiresIncludeContextChanges: true + ) + } + + private func relationshipPreservedLiveChats( + omittedFrom loadedItems: [Model], + descriptor: CodexFetchDescriptor + ) -> [CodexChat] { + preservedLiveChats( + omittedFrom: loadedItems, + descriptor: descriptor, + requiresIncludeContextChanges: false + ) + } + + private func preservedLiveChats( + omittedFrom loadedItems: [Model], + descriptor: CodexFetchDescriptor, + requiresIncludeContextChanges: Bool + ) -> [CodexChat] { + guard Model.self == CodexChat.self, + canPreserveLiveChats( + for: descriptor, + requiresIncludeContextChanges: requiresIncludeContextChanges + ) + else { + return [] + } + return preservedLiveChatsForFetchedRelationships( + omittedFrom: loadedItems as? [CodexChat] ?? [], + descriptor: descriptor, + requiresIncludeContextChanges: requiresIncludeContextChanges + ) + } + + private func preservedLiveChatsForFetchedRelationships( + omittedFrom loadedChats: [CodexChat], + descriptor: CodexFetchDescriptor, + requiresIncludeContextChanges: Bool + ) -> [CodexChat] { + guard canPreserveLiveChats( + for: descriptor, + requiresIncludeContextChanges: requiresIncludeContextChanges + ) else { + return [] + } + let loadedChatIDs = Set(loadedChats.map(\.id)) + return chatsByID.values.filter { chat in + loadedChatIDs.contains(chat.id) == false + && shouldPreserveLiveFetchedChat(chat) + && shouldIncludeLiveFetchedChat(chat, descriptor: descriptor) + } + } + + package func shouldPreserveLiveFetchedChat(_ chat: CodexChat) -> Bool { + guard chatsByID[chat.id] === chat else { + return false + } + if activeChatObservationsByID[chat.id]?.isFinished == false { + return true + } + if chat.status?.isActive == true { + return true + } + if chat.phase == .loading { + return true + } + return false + } + + private func canPreserveLiveChats( + for descriptor: CodexFetchDescriptor, + requiresIncludeContextChanges: Bool + ) -> Bool { + let plan = chatQueryPlan(for: descriptor) + return (requiresIncludeContextChanges == false || descriptor.includeContextChanges) + && descriptor.normalizedFetchOffset == 0 + && plan?.membershipRequiresServerRefresh != true + } + + private func shouldIncludeLiveFetchedChat( + _ chat: CodexChat, + descriptor: CodexFetchDescriptor + ) -> Bool { + guard let plan = chatQueryPlan(for: descriptor) else { + return chat.isArchived == false + && CodexThreadCandidateSourceScope.defaultUserVisible.matches( + CodexChatRecord(chat: chat) + ) + } + return plan.matchesLocalCandidate(chat) + } + + private func workspaceIfLoaded(for url: URL) -> CodexWorkspace? { + let id = CodexWorkspaceID(rawValue: Self.standardizedDirectoryURL(url).path) + return workspacesByID[id] + } + + private func register(_ results: any CodexFetchedResultsRegistration) { + fetchedResults.removeAll { $0.value == nil } + fetchedResults.append(WeakFetchedResultsRegistration(results)) + } + + private func insertChatIntoRegisteredResults(_ chat: CodexChat, archived: Bool) async { + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + await registration.value?.insert(chat, archived: archived) + } + } + + private func archiveChatInRegisteredResults( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup? + ) async { + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + await registration.value?.archive(chat, workspace: workspace, group: group) + } + } + + private func revalidateChatInRegisteredResults( + _ chat: CodexChat, + previousWorkspace: CodexWorkspace?, + previousGroup: CodexWorkspaceGroup?, + archived: Bool, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + await revalidateChatsInRegisteredResults( + [CodexFetchedChatRevalidation( + chat: chat, + previousWorkspace: previousWorkspace, + previousGroup: previousGroup, + archived: archived + )], + excluding: excludedRegistration + ) + } + + private func revalidateChatsInRegisteredResults( + _ changes: [CodexFetchedChatRevalidation], + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + guard changes.isEmpty == false else { + return + } + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + guard let value = registration.value else { + continue + } + if let excludedRegistration, + (value as AnyObject) === (excludedRegistration as AnyObject) + { + continue + } + await value.revalidate(changes) + } + } + + private func removeChatFromRegisteredResults( + _ chat: CodexChat, + workspace: CodexWorkspace?, + group: CodexWorkspaceGroup?, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + guard let value = registration.value else { + continue + } + if let excludedRegistration, + (value as AnyObject) === (excludedRegistration as AnyObject) + { + continue + } + await value.remove(chat, workspace: workspace, group: group) + } + } + + private func removeChatsFromRegisteredResults( + _ removedChats: [( + chat: CodexChat, + workspace: CodexWorkspace, + group: CodexWorkspaceGroup? + )], + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async { + for removedChat in removedChats { + await removeChatFromRegisteredResults( + removedChat.chat, + workspace: removedChat.workspace, + group: removedChat.group, + excluding: excludedRegistration + ) + } + } + + private func refreshWorkspaceInRegisteredResults( + _ workspace: CodexWorkspace, + archived: Bool, + removedChats: [CodexChat] + ) async { + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + await registration.value?.refresh(workspace, archived: archived, removedChats: removedChats) + } + } + + private func refreshWorkspaceGroupInRegisteredResults( + _ group: CodexWorkspaceGroup, + archived: Bool, + removedChats: [CodexChat] + ) async { + fetchedResults.removeAll { $0.value == nil } + for registration in fetchedResults { + await registration.value?.refresh(group, archived: archived, removedChats: removedChats) + } + } + + private func fetchAndApplyAllThreadSnapshots( + matching descriptor: CodexFetchDescriptor, + archiveScope: Bool? = nil, + appliedArchived: Bool, + scopedWorkspaceURL: URL? = nil, + excluding excludedRegistration: (any CodexFetchedResultsRegistration)? = nil + ) async throws -> [CodexChat] { + let candidates = try await fetchAllThreadSnapshots( + matching: descriptor, + archived: archiveScope + ) + try Task.checkCancellation() + return await applyFetchedSnapshots( + candidates, + archived: appliedArchived, + scopedWorkspaceURL: scopedWorkspaceURL, + excluding: excludedRegistration + ) + } + + private func fetchAllThreadSnapshots( + matching descriptor: CodexFetchDescriptor, + archived archiveScope: Bool? = nil + ) async throws -> [FetchedThreadCandidate] { + var candidates: [FetchedThreadCandidate] = [] + var indexesByID: [CodexThreadID: Int] = [:] + + for var query in threadQueries( + from: descriptor, + includePaging: false, + archived: archiveScope + ) { + let sourceKinds = query.sourceKinds + // Created/updated cursors in the pinned app-server do not contain a thread-ID + // tie-breaker. Enumerate with its stable recency cursor, then apply the requested + // effective ordering locally. + query.sortDirection = .descending + query.sortKey = .recencyAt + var cursor: String? + + repeat { + try Task.checkCancellation() + query.cursor = cursor + let page = try await appServer.listThreads(query) + for thread in page.threads { + if let index = indexesByID[thread.id] { + candidates[index].append( + snapshot: thread, + sourceKinds: sourceKinds + ) + } else { + indexesByID[thread.id] = candidates.count + candidates.append(FetchedThreadCandidate( + snapshot: thread, + sourceKinds: sourceKinds + )) + } + } + cursor = page.nextCursor + } while cursor != nil + } + + return candidates + } + + private func chatQueryPlan( + for descriptor: CodexFetchDescriptor + ) -> CodexThreadQueryPlan? { + guard Model.self == CodexChat.self else { + return nil + } + return try? CodexThreadQueryPlan( + descriptor: descriptor as! CodexFetchDescriptor + ) + } + + private func archivedScope( + for descriptor: CodexFetchDescriptor + ) -> Bool? { + chatQueryPlan(for: descriptor)?.archived + } + + private func workspaceFilters( + for descriptor: CodexFetchDescriptor + ) -> [URL]? { + chatQueryPlan(for: descriptor)?.workspaces + } + + private func singleWorkspaceScope( + for descriptor: CodexFetchDescriptor + ) -> URL? { + chatQueryPlan(for: descriptor)?.singleWorkspace + } + + private func localPage( + _ items: [Model], + for descriptor: CodexFetchDescriptor, + cursor: String? + ) -> CodexFetchPage { + let offset = + cursor == nil + ? descriptor.normalizedFetchOffset + : localCursorOffset(from: cursor) + let start = min(offset, items.count) + guard let limit = descriptor.fetchLimit else { + return CodexFetchPage( + items: Array(items[start.. 0 ? localCursor(for: 0) : nil + ) + } + guard limit > 0 else { + return CodexFetchPage(items: [], nextCursor: nil, backwardsCursor: nil) + } + + let end = min(start + limit, items.count) + let previousStart = max(0, start - limit) + return CodexFetchPage( + items: Array(items[start.. 0 ? localCursor(for: previousStart) : nil + ) + } + + private func canUseServerOrderedPages( + for descriptor: CodexFetchDescriptor, + cursor: String? + ) -> Bool { + if descriptor.normalizedFetchOffset > 0 { + return false + } + if cursor?.hasPrefix(Self.localCursorPrefix) == true { + return false + } + guard let plan = chatQueryPlan(for: descriptor) else { + return false + } + guard plan.archived != nil else { + return false + } + guard plan.serverPredicateIsComplete else { + return false + } + guard plan.candidateSourceScope.requiresCompositeFetch == false else { + return false + } + return plan.sortPlans.isEmpty + || (plan.sortPlans.count == 1 && plan.sortPlans[0].key == .recencyAt) + } + + private func canUseBoundedCompositeRecencyPage( + for descriptor: CodexFetchDescriptor, + plan: CodexThreadQueryPlan, + cursor: String? + ) -> Bool { + guard cursor == nil || cursor?.hasPrefix(Self.localCursorPrefix) == true else { + return false + } + guard plan.candidateSourceScope == .defaultUserVisible, + plan.archived != nil, + plan.serverPredicateIsComplete, + plan.sortPlans.count == 1, + plan.sortPlans[0].key == .recencyAt, + let fetchLimit = descriptor.fetchLimit, + fetchLimit > 0 + else { + return false + } + return true + } + + package func localCursor(for offset: Int) -> String { + "\(Self.localCursorPrefix)\(offset)" + } + + package func localCursorOffset(from cursor: String?) -> Int { + guard let cursor, + cursor.hasPrefix(Self.localCursorPrefix) + else { + return 0 + } + + let rawOffset = cursor.dropFirst(Self.localCursorPrefix.count) + guard let offset = Int(rawOffset), offset > 0 else { + return 0 + } + return offset + } + + private func threadQuery( + from descriptor: CodexFetchDescriptor, + cursor: String? = nil, + includePaging: Bool = true, + archived archiveScope: Bool? = nil + ) + -> CodexThreadQuery + { + if let plan = chatQueryPlan(for: descriptor) { + return plan.threadQuery( + cursor: cursor, + includePaging: includePaging, + archived: archiveScope + ) + } + let sortPlans = descriptor.sortBy.map(CodexSortPlan.afterValidation) + let serverSort = sortPlans.first { sortDescriptor in + switch sortDescriptor.key { + case .createdAt, .updatedAt, .recencyAt: + return true + case .name: + return false + } + } + return CodexThreadQuery( + cursor: includePaging ? cursor : nil, + limit: includePaging ? descriptor.fetchLimit : nil, + sortDirection: serverSort?.threadSortDirection, + sortKey: serverSort?.threadSortKey + ) + } + + private func threadQueries( + from descriptor: CodexFetchDescriptor, + cursor: String? = nil, + includePaging: Bool = true, + archived archiveScope: Bool? = nil + ) -> [CodexThreadQuery] { + let candidateSourceScope = chatQueryPlan(for: descriptor)?.candidateSourceScope + ?? .defaultUserVisible + let baseQuery = threadQuery( + from: descriptor, + cursor: cursor, + includePaging: includePaging, + archived: archiveScope + ) + return candidateSourceScope.sourceKindFilters.map { sourceKinds in + var query = baseQuery + query.sourceKinds = sourceKinds + return query + } + } + + private func sortLocallyFetchedChats( + _ chats: [CodexChat], + using descriptor: CodexFetchDescriptor + ) -> [CodexChat] { + guard descriptor.sortBy.isEmpty, + chatQueryPlan(for: descriptor)?.candidateSourceScope.requiresCompositeFetch == true + else { + return sort(chats, using: descriptor.sortBy) + } + return sort( + chats, + using: [CodexSortDescriptor(\CodexChat.createdAt, order: .reverse)] + ) + } + + private func sectionIdentity( + for item: Model, + descriptor: CodexSectionDescriptor + ) -> (id: CodexFetchSectionID, title: String) { + let sectionKey: CodexSectionKey + do { + sectionKey = try descriptor.resolveKey() + } catch { + preconditionFailure( + "CodexSectionDescriptor was used before successful validation: \(error)" + ) + } + switch sectionKey { + case .workspace: + if let chat = item as? CodexChat, let workspace = chat.workspace { + return (.workspace(workspace.id), workspace.name) + } + case .workspaceGroup: + if let workspace = item as? CodexWorkspace, let group = workspace.workspaceGroup { + return (.workspaceGroup(group.id), group.name) + } + if let chat = item as? CodexChat, let group = chat.workspace?.workspaceGroup { + return (.workspaceGroup(group.id), group.name) + } + } + return (.unknown("unknown"), "Unknown") + } + + private func sort( + _ chats: [CodexChat], + using descriptors: [CodexSortDescriptor] + ) + -> [CodexChat] + { + guard descriptors.isEmpty == false else { + return chats + } + let plans = descriptors.map(CodexSortPlan.afterValidation) + return sortModels( + chats, + using: plans, + stableID: { $0.id.rawValue }, + tieBreakOrder: plans[0].order + ) { plan, lhs, rhs in + plan.compare(lhs, rhs) + } + } + + private func sort( + _ workspaces: [CodexWorkspace], + using descriptors: [CodexSortDescriptor] + ) -> [CodexWorkspace] { + let plans = descriptors.map(CodexSortPlan.afterValidation) + return sortModels( + workspaces, + using: plans, + stableID: { $0.id.rawValue }, + tieBreakOrder: plans.first?.order ?? .forward + ) { plan, lhs, rhs in + plan.compare(lhs, rhs) + } + } + + private func sort( + _ groups: [CodexWorkspaceGroup], + using descriptors: [CodexSortDescriptor] + ) -> [CodexWorkspaceGroup] { + let plans = descriptors.map(CodexSortPlan.afterValidation) + return sortModels( + groups, + using: plans, + stableID: { $0.id.rawValue }, + tieBreakOrder: plans.first?.order ?? .forward + ) { plan, lhs, rhs in + plan.compare(lhs, rhs) + } + } + + private func sortModels( + _ models: [Model], + using descriptors: [Descriptor], + stableID: (Model) -> String, + tieBreakOrder: SortOrder, + compare: (Descriptor, Model, Model) -> ComparisonResult + ) -> [Model] { + return models.sorted { lhs, rhs in + shouldSortBefore( + lhs, + rhs, + using: descriptors, + stableID: stableID, + tieBreakOrder: tieBreakOrder, + compare: compare + ) + } + } + + private func shouldSortBefore( + _ lhs: Model, + _ rhs: Model, + using descriptors: [Descriptor], + stableID: (Model) -> String, + tieBreakOrder: SortOrder, + compare: (Descriptor, Model, Model) -> ComparisonResult + ) -> Bool { + for descriptor in descriptors { + switch compare(descriptor, lhs, rhs) { + case .orderedAscending: + return true + case .orderedDescending: + return false + case .orderedSame: + continue + } + } + let lhsID = stableID(lhs) + let rhsID = stableID(rhs) + guard lhsID != rhsID else { + return false + } + return tieBreakOrder == .forward ? lhsID < rhsID : lhsID > rhsID + } + + private func unique(_ models: [Model]) -> [Model] { + var seen: Set = [] + var result: [Model] = [] + for model in models where seen.insert(model.id).inserted { + result.append(model) + } + return result + } + + private static func standardizedDirectoryURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func displayName(for url: URL) -> String { + let name = url.lastPathComponent + return name.isEmpty ? url.path : name + } +} + +@available(*, unavailable, message: "contexts cannot be shared across concurrency contexts") +extension CodexModelContext: @unchecked Sendable {} + +private extension CodexReviewTarget { + var dataKitPreview: String { + switch self { + case .uncommittedChanges: + return "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings." + case .baseBranch(let branch): + return "Review the code changes against the base branch '\(branch)'." + case .commit(let sha, let title): + if let title, title.isEmpty == false { + return "Review the code changes introduced by commit \(sha) (\"\(title)\"). Provide prioritized, actionable findings." + } + return "Review the code changes introduced by commit \(sha). Provide prioritized, actionable findings." + case .custom(let instructions): + let preview = instructions.trimmingCharacters(in: .whitespacesAndNewlines) + return preview.isEmpty ? "Review code changes." : preview + } + } +} + +extension ComparisonResult { + fileprivate var reversed: ComparisonResult { + switch self { + case .orderedAscending: + return .orderedDescending + case .orderedDescending: + return .orderedAscending + case .orderedSame: + return .orderedSame + } + } +} + +private final class WeakFetchedResultsRegistration { + weak var value: (any CodexFetchedResultsRegistration)? + + init(_ value: any CodexFetchedResultsRegistration) { + self.value = value + } +} + +private struct CodexWorkspaceGroupIdentity: Sendable { + var id: CodexWorkspaceGroupID + var title: String + + static func identity( + for workspaceURL: URL, + fileManager: FileManager = .default + ) -> CodexWorkspaceGroupIdentity { + guard + let gitMetadataURL = enclosingGitMetadataURL( + startingAt: workspaceURL, fileManager: fileManager) + else { + return .cwd(workspaceURL) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: gitMetadataURL.path, isDirectory: &isDirectory) else { + return .cwd(workspaceURL) + } + + let gitRootURL = gitMetadataURL.deletingLastPathComponent() + let commonDirURL: URL? + if isDirectory.boolValue { + commonDirURL = gitMetadataURL + } else if let gitDirURL = linkedGitDirURL(from: gitMetadataURL) { + commonDirURL = linkedCommonDirURL(for: gitDirURL) ?? gitDirURL + } else { + commonDirURL = nil + } + + guard let commonDirURL else { + return .cwd(workspaceURL) + } + + let standardizedCommonDirURL = commonDirURL.standardizedFileURL.resolvingSymlinksInPath() + return CodexWorkspaceGroupIdentity( + id: .init(rawValue: "git-common:\(standardizedCommonDirURL.path)"), + title: sectionTitle( + commonDirURL: standardizedCommonDirURL, + gitRootURL: gitRootURL, + fallbackURL: workspaceURL + ) + ) + } + + private static func cwd(_ url: URL) -> CodexWorkspaceGroupIdentity { + CodexWorkspaceGroupIdentity( + id: .init(rawValue: "cwd:\(url.path)"), + title: displayName(for: url) + ) + } + + private static func enclosingGitMetadataURL(startingAt url: URL, fileManager: FileManager) + -> URL? + { + var directoryPath = url.standardizedFileURL.path + while true { + let gitPath = (directoryPath as NSString).appendingPathComponent(".git") + if fileManager.fileExists(atPath: gitPath) { + return URL(fileURLWithPath: gitPath) + } + + let parentPath = (directoryPath as NSString).deletingLastPathComponent + guard parentPath != directoryPath, parentPath.isEmpty == false else { + return nil + } + directoryPath = parentPath + } + } + + private static func linkedGitDirURL(from gitFileURL: URL) -> URL? { + guard let contents = try? String(contentsOf: gitFileURL, encoding: .utf8), + let firstLine = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + + let prefix = "gitdir:" + let line = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.lowercased().hasPrefix(prefix) else { + return nil + } + + let path = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard path.isEmpty == false else { + return nil + } + return resolvedURL(path: path, relativeTo: gitFileURL.deletingLastPathComponent()) + } + + private static func linkedCommonDirURL(for gitDirURL: URL) -> URL? { + let commonDirFileURL = gitDirURL.appendingPathComponent("commondir") + guard let contents = try? String(contentsOf: commonDirFileURL, encoding: .utf8), + let firstLine = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + + let path = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard path.isEmpty == false else { + return nil + } + return resolvedURL(path: path, relativeTo: gitDirURL) + } + + private static func resolvedURL(path: String, relativeTo baseURL: URL) -> URL { + let url = + path.hasPrefix("/") + ? URL(fileURLWithPath: path, isDirectory: true) + : baseURL.appendingPathComponent(path, isDirectory: true) + return url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func sectionTitle( + commonDirURL: URL, + gitRootURL: URL, + fallbackURL: URL + ) -> String { + if commonDirURL.lastPathComponent == ".git" { + let title = commonDirURL.deletingLastPathComponent().lastPathComponent + if title.isEmpty == false { + return title + } + } + + let commonDirName = commonDirURL.lastPathComponent + if commonDirName.hasSuffix(".git"), commonDirName.count > ".git".count { + return String(commonDirName.dropLast(".git".count)) + } + + let rootTitle = gitRootURL.lastPathComponent + return rootTitle.isEmpty ? displayName(for: fallbackURL) : rootTitle + } + + private static func displayName(for url: URL) -> String { + let name = url.lastPathComponent + return name.isEmpty ? url.path : name + } +} diff --git a/Sources/CodexDataKit/CodexQuery.swift b/Sources/CodexDataKit/CodexQuery.swift new file mode 100644 index 00000000..04d0af3e --- /dev/null +++ b/Sources/CodexDataKit/CodexQuery.swift @@ -0,0 +1,170 @@ +import Foundation +import SwiftUI + +extension EnvironmentValues { + @Entry public var codexModelContext: CodexModelContext? = nil +} + +extension View { + public func codexModelContainer(_ container: CodexModelContainer) -> some View { + environment(\.codexModelContext, container.mainContext) + } + + public func codexModelContext(_ context: CodexModelContext) -> some View { + environment(\.codexModelContext, context) + } +} + +public struct CodexQueryResults: RandomAccessCollection { + public typealias Index = Array.Index + public typealias Element = Model + + public var items: [Model] + public var sections: [CodexFetchSection] + public var phase: CodexFetchPhase + + public init( + items: [Model] = [], + sections: [CodexFetchSection] = [], + phase: CodexFetchPhase = .idle + ) { + self.items = items + self.sections = sections + self.phase = phase + } + + public var startIndex: Index { + items.startIndex + } + + public var endIndex: Index { + items.endIndex + } + + public subscript(position: Index) -> Model { + items[position] + } +} + +@MainActor +@propertyWrapper +public struct CodexQuery: @preconcurrency DynamicProperty { + @Environment(\.codexModelContext) private var modelContext + @State private var fetchedResults: CodexFetchedResults? + private let fetchDescriptor: CodexFetchDescriptor + private let sectionBy: CodexSectionDescriptor? + + public init( + _ descriptor: CodexFetchDescriptor = .init(), + sectionBy: CodexSectionDescriptor? = nil + ) { + self.fetchDescriptor = descriptor + self.sectionBy = sectionBy + } + + public init( + filter: Predicate? = nil, + sort: [CodexSortDescriptor] = [], + sectionBy: CodexSectionDescriptor? = nil + ) { + self.fetchDescriptor = CodexFetchDescriptor( + predicate: filter, + sortBy: sort + ) + self.sectionBy = sectionBy + } + + public init( + filter: Predicate? = nil, + sort keyPath: KeyPath & Sendable, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) { + self.init( + filter: filter, + sort: [CodexSortDescriptor(keyPath, order: order)], + sectionBy: sectionBy + ) + } + + public init( + filter: Predicate? = nil, + sort keyPath: KeyPath & Sendable, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) { + self.init( + filter: filter, + sort: [CodexSortDescriptor(keyPath, order: order)], + sectionBy: sectionBy + ) + } + + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) { + self.init( + filter: filter, + sort: [CodexSortDescriptor( + keyPath, + comparator: comparator, + order: order + )], + sectionBy: sectionBy + ) + } + + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) { + self.init( + filter: filter, + sort: [CodexSortDescriptor( + keyPath, + comparator: comparator, + order: order + )], + sectionBy: sectionBy + ) + } + + public var wrappedValue: CodexQueryResults { + guard let fetchedResults else { + return CodexQueryResults() + } + return CodexQueryResults( + items: fetchedResults.items, + sections: fetchedResults.sections, + phase: fetchedResults.phase + ) + } + + public mutating func update() { + guard let modelContext else { + preconditionFailure( + "CodexQuery requires a CodexModelContext in the SwiftUI environment." + ) + } + + if let fetchedResults, + fetchedResults.modelContext === modelContext, + fetchedResults.querySignature == fetchDescriptor.querySignature, + fetchedResults.sectionBy == sectionBy { + return + } + + let results = modelContext.fetchedResults(for: fetchDescriptor, sectionedBy: sectionBy) + fetchedResults = results + Task { + try? await results.performFetch() + } + } +} diff --git a/Sources/CodexDataKit/CodexThreadQueryPlan.swift b/Sources/CodexDataKit/CodexThreadQueryPlan.swift new file mode 100644 index 00000000..79d6e707 --- /dev/null +++ b/Sources/CodexDataKit/CodexThreadQueryPlan.swift @@ -0,0 +1,1472 @@ +import CodexAppServerKit +import Foundation + +package struct CodexChatRecord: Hashable, Sendable { + package var id: CodexThreadID + package var name: String? + package var preview: String? + package var title: String + package var modelProvider: String? + package var isArchived: Bool + package var workspaceID: CodexWorkspaceID? + package var workspaceURL: URL? + package var workspaceGroupID: CodexWorkspaceGroupID? + package var source: CodexThreadSessionSource? + package var sourceKind: CodexThreadSourceKind? + package var sourceProvenance: CodexThreadListSourceProvenance? + package var searchableText: String + package var createdAt: Date? + package var updatedAt: Date? + package var recencyAt: Date? + + package init(chat: CodexChat) { + id = chat.id + name = chat.name + preview = chat.preview + title = chat.title + modelProvider = chat.modelProvider + isArchived = chat.isArchived + workspaceID = chat.workspaceID + workspaceURL = chat.workspace?.url + workspaceGroupID = chat.workspaceGroupID + source = chat.source + sourceKind = chat.sourceKind + sourceProvenance = chat.threadListSourceProvenance + searchableText = chat.searchableText + createdAt = chat.createdAt + updatedAt = chat.updatedAt + recencyAt = chat.recencyAt + } +} + +package enum CodexThreadCandidateSourceScope: Hashable, Sendable { + case defaultUserVisible + case explicit([CodexThreadSourceKind]) + + package static let userVisibleNoninteractiveKinds: [CodexThreadSourceKind] = [ + .exec, + .appServer, + .subAgentReview, + .subAgentCompact, + .subAgentThreadSpawn, + .subAgentOther, + .unknown, + ] + + package var sourceKindFilters: [[CodexThreadSourceKind]?] { + switch self { + case .defaultUserVisible: + // The app-server's nil filter is the only way to include its supported custom + // interactive sources. A second disjoint query adds user-visible noninteractive + // sources without admitting internal memory-consolidation sessions. + [nil, Self.userVisibleNoninteractiveKinds] + case .explicit(let sourceKinds): + [sourceKinds] + } + } + + package var requiresCompositeFetch: Bool { + switch self { + case .defaultUserVisible: + true + case .explicit: + false + } + } + + package func matches(_ record: CodexChatRecord) -> Bool { + if record.source != nil || record.sourceKind != nil { + return matches(source: record.source, sourceKind: record.sourceKind) + } + guard let provenance = record.sourceProvenance else { + return false + } + return provenance.possibilities.allSatisfy(contains) + } + + package func matches(_ resolution: CodexThreadSourceResolution) -> Bool { + switch resolution { + case .exact(let source): + matches(source: source, sourceKind: source.sourceKind) + case .kindOnly(let sourceKind): + matches(source: nil, sourceKind: sourceKind) + case .partitionProven(let provenance): + provenance.possibilities.allSatisfy(contains) + case .unresolved, .knownNull: + false + } + } + + package func contains(_ possibility: CodexThreadListSourcePossibility) -> Bool { + switch possibility { + case .kind(let sourceKind): + matches(source: nil, sourceKind: sourceKind) + case .supportedCustomInteractive: + if case .defaultUserVisible = self { + true + } else { + false + } + } + } + + package func matches( + source: CodexThreadSessionSource?, + sourceKind: CodexThreadSourceKind? + ) -> Bool { + if let source { + switch self { + case .defaultUserVisible: + return Self.matchesDefaultUserVisibleSource(source) + case .explicit(let sourceKinds): + return sourceKinds.contains { source.matches(sourceKind: $0) } + } + } + guard let sourceKind else { + return false + } + switch self { + case .defaultUserVisible: + return sourceKind == .cli + || sourceKind == .vscode + || Self.userVisibleNoninteractiveKinds.contains(sourceKind) + case .explicit(let sourceKinds): + return sourceKinds.contains { filterKind in + if filterKind == .subAgent { + return sourceKind == .subAgent + || sourceKind == .subAgentReview + || sourceKind == .subAgentCompact + || sourceKind == .subAgentThreadSpawn + || sourceKind == .subAgentOther + } + return sourceKind == filterKind + } + } + } + + private static func matchesDefaultUserVisibleSource( + _ source: CodexThreadSessionSource + ) -> Bool { + switch source { + case .cli, .vscode, .exec, .appServer, .unknown: + return true + case .custom(let value): + return value == "atlas" || value == "chatgpt" + case .subAgent(.review), .subAgent(.compact), .subAgent(.threadSpawn), + .subAgent(.other): + return true + case .subAgent(.memoryConsolidation): + return false + } + } +} + +package struct CodexThreadQueryPlan: Sendable { + package typealias RecordPredicate = @Sendable (CodexChatRecord) -> Bool + + package var predicate: RecordPredicate? + package var predicateSignature: CodexChatPredicateSignature? + package var sortPlans: [CodexSortPlan] + package var fetchLimit: Int? + package var fetchOffset: Int + package var includeContextChanges: Bool + private var serverFilter: CodexThreadServerFilter + + package init(descriptor: CodexFetchDescriptor) throws { + if let predicate = descriptor.predicate { + let lowered = try makeCodexChatRecordPredicate(predicate) + self.predicate = lowered.predicate + self.predicateSignature = lowered.signature + self.serverFilter = try CodexThreadServerFilter(signature: lowered.signature) + } else { + self.predicate = { $0.isArchived == false } + self.predicateSignature = nil + self.serverFilter = .defaultChatFilter + } + self.sortPlans = try descriptor.validatedSortPlans() + self.fetchLimit = descriptor.fetchLimit + self.fetchOffset = descriptor.normalizedFetchOffset + self.includeContextChanges = descriptor.includeContextChanges + } + + package var signature: CodexFetchDescriptorSignature { + CodexFetchDescriptorSignature( + modelKind: .chat, + predicate: predicateSignature, + sortPlans: sortPlans.map(\.signature), + fetchLimit: fetchLimit, + fetchOffset: fetchOffset, + includeContextChanges: includeContextChanges, + validationFailure: nil + ) + } + + package var archived: Bool? { + serverFilter.archived + } + + package var archiveScopes: [Bool] { + archived.map { [$0] } ?? [false, true] + } + + package var workspaces: [URL]? { + serverFilter.workspaces + } + + package var singleWorkspace: URL? { + guard let workspaces, workspaces.count == 1 else { + return nil + } + return workspaces[0] + } + + package var searchTerm: String? { + serverFilter.searchTerm + } + + package var modelProviders: [String]? { + serverFilter.modelProviders + } + + package var sourceKinds: [CodexThreadSourceKind]? { + serverFilter.sourceKinds + } + + package var candidateSourceScope: CodexThreadCandidateSourceScope { + sourceKinds.map(CodexThreadCandidateSourceScope.explicit) ?? .defaultUserVisible + } + + package var serverPredicateIsComplete: Bool { + serverFilter.isComplete + } + + package var membershipRequiresServerRefresh: Bool { + serverFilter.requiresServerRefreshForMembership + } + + package var usesServerOwnedOrdering: Bool { + sortPlans.isEmpty || sortPlans.first?.key == .recencyAt + } + + package func mutationStrategy( + for operation: CodexFetchedResultsMutationOperation + ) -> CodexFetchedResultsMutationStrategy { + let requiresAuthoritativeRefresh = + membershipRequiresServerRefresh || usesServerOwnedOrdering + switch operation { + case .insert, .archive, .relationshipRefresh: + return requiresAuthoritativeRefresh ? .refreshLoadedWindow : .applyLocally + case .revalidate(let affectsMembership, let hasNextPage): + return requiresAuthoritativeRefresh + || (affectsMembership && (hasNextPage || fetchOffset > 0)) + ? .refreshLoadedWindow + : .applyLocally + case .remove(let hasNextPage): + return requiresAuthoritativeRefresh || fetchOffset > 0 || hasNextPage + ? .refreshLoadedWindow + : .removeLocally + } + } + + package func matchesLocalCandidate(_ chat: CodexChat) -> Bool { + matchesLocalCandidate(CodexChatRecord(chat: chat)) + } + + package func matchesLocalCandidate(_ record: CodexChatRecord) -> Bool { + guard serverFilter.matchesArchiveScope(record) else { + return false + } + if record.source != nil || record.sourceKind != nil { + return candidateSourceScope.matches(record) + && (predicate?(record) ?? true) + } + guard let provenance = record.sourceProvenance else { + return false + } + return provenance.possibilities.allSatisfy { possibility in + guard candidateSourceScope.contains(possibility) else { + return false + } + var projectedRecord = record + projectedRecord.sourceKind = possibility.projectedSourceKind + return predicate?(projectedRecord) ?? true + } + } + + package func matchesServerResponse(_ chat: CodexChat) -> Bool { + let record = CodexChatRecord(chat: chat) + guard candidateSourceScope.matches(record) else { + return false + } + return serverFilter.isComplete || matchesLocalCandidate(record) + } + + package func matchesArchiveScope(_ archived: Bool) -> Bool { + serverFilter.matchesArchiveScope(archived) + } + + package func threadQuery( + cursor: String?, + includePaging: Bool, + archived archiveScope: Bool? = nil + ) -> CodexThreadQuery { + let serverSort = sortPlans.first { sortPlan in + switch sortPlan.key { + case .createdAt, .updatedAt, .recencyAt: + return true + case .name: + return false + } + } + return CodexThreadQuery( + archived: archiveScope ?? archived, + cursor: includePaging ? cursor : nil, + workspaces: workspaces, + limit: includePaging ? fetchLimit : nil, + searchTerm: searchTerm, + modelProviders: modelProviders, + sortDirection: serverSort?.threadSortDirection, + sortKey: serverSort?.threadSortKey, + sourceKinds: sourceKinds + ) + } +} + +package enum CodexFetchedResultsMutationOperation: Sendable, Equatable { + case insert + case archive + case revalidate(affectsMembership: Bool, hasNextPage: Bool) + case remove(hasNextPage: Bool) + case relationshipRefresh +} + +package enum CodexFetchedResultsMutationStrategy: Sendable, Equatable { + case applyLocally + case removeLocally + case refreshLoadedWindow +} + +package enum CodexFetchDescriptorModelKind: Hashable, Sendable { + case chat + case workspace + case workspaceGroup + case unsupported(String) +} + +package struct CodexFetchDescriptorSignature: Hashable, Sendable { + package var modelKind: CodexFetchDescriptorModelKind + package var predicate: CodexChatPredicateSignature? + package var sortPlans: [CodexSortPlanSignature] + package var fetchLimit: Int? + package var fetchOffset: Int + package var includeContextChanges: Bool + package var validationFailure: CodexFetchValidationError? +} + +package struct CodexSortPlanSignature: Hashable, Sendable { + package var path: CodexSortPath + package var order: SortOrder + package var stringComparator: String.StandardComparator? +} + +extension CodexSortPlan { + package var signature: CodexSortPlanSignature { + .init(path: path, order: order, stringComparator: stringComparator) + } +} + +extension CodexFetchDescriptor { + package var querySignature: CodexFetchDescriptorSignature { + do { + if let fetchLimit, fetchLimit < 0 { + throw CodexFetchValidationError.negativeFetchLimit(fetchLimit) + } + if let fetchOffset, fetchOffset < 0 { + throw CodexFetchValidationError.negativeFetchOffset(fetchOffset) + } + if predicate != nil, Model.self != CodexChat.self { + throw CodexFetchValidationError.unsupportedPredicate( + String(describing: Model.self) + ) + } + let kind: CodexFetchDescriptorModelKind + if Model.self == CodexChat.self { + return try CodexThreadQueryPlan( + descriptor: self as! CodexFetchDescriptor + ).signature + } else if Model.self == CodexWorkspace.self { + kind = .workspace + } else if Model.self == CodexWorkspaceGroup.self { + kind = .workspaceGroup + } else { + throw CodexFetchValidationError.unsupportedModel(String(describing: Model.self)) + } + return CodexFetchDescriptorSignature( + modelKind: kind, + predicate: nil, + sortPlans: try validatedSortPlans().map(\.signature), + fetchLimit: fetchLimit, + fetchOffset: normalizedFetchOffset, + includeContextChanges: includeContextChanges, + validationFailure: nil + ) + } catch let failure as CodexFetchValidationError { + return CodexFetchDescriptorSignature( + modelKind: .unsupported(String(describing: Model.self)), + predicate: nil, + sortPlans: [], + fetchLimit: fetchLimit, + fetchOffset: normalizedFetchOffset, + includeContextChanges: includeContextChanges, + validationFailure: failure + ) + } catch { + preconditionFailure("Unexpected fetch descriptor validation error: \(error)") + } + } +} + +package enum CodexChatPredicateKey: Hashable, Sendable { + case isArchived + case modelProvider + case workspaceID + case sourceKind + case searchableText +} + +package enum CodexChatPredicateValue: Hashable, Sendable { + case key(CodexChatPredicateKey) + case bool(Bool) + case string(String) + case optionalString(String?) + case workspaceID(CodexWorkspaceID) + case optionalWorkspaceID(CodexWorkspaceID?) + case sourceKind(CodexThreadSourceKind) + case optionalSourceKind(CodexThreadSourceKind?) + case stringArray([String]) + case workspaceIDArray([CodexWorkspaceID]) + case sourceKindArray([CodexThreadSourceKind]) + case nilLiteral(String) +} + +extension CodexChatPredicateValue { + fileprivate func codexPredicateEquals(_ other: Self) -> Bool { + switch (self, other) { + case (.nilLiteral, .optionalString(.none)), + (.optionalString(.none), .nilLiteral), + (.nilLiteral, .optionalWorkspaceID(.none)), + (.optionalWorkspaceID(.none), .nilLiteral), + (.nilLiteral, .optionalSourceKind(.none)), + (.optionalSourceKind(.none), .nilLiteral): + return true + case (.optionalString(.some(let lhs)), .string(let rhs)), + (.string(let rhs), .optionalString(.some(let lhs))): + return lhs == rhs + case (.optionalWorkspaceID(.some(let lhs)), .workspaceID(let rhs)), + (.workspaceID(let rhs), .optionalWorkspaceID(.some(let lhs))): + return lhs == rhs + case (.optionalSourceKind(.some(let lhs)), .sourceKind(let rhs)), + (.sourceKind(let rhs), .optionalSourceKind(.some(let lhs))): + return lhs == rhs + default: + return self == other + } + } +} + +package indirect enum CodexChatPredicateSignature: Hashable, Sendable { + case bool(CodexChatPredicateValue) + case equal(CodexChatPredicateValue, CodexChatPredicateValue) + case notEqual(CodexChatPredicateValue, CodexChatPredicateValue) + case localizedStandardContains(CodexChatPredicateValue, CodexChatPredicateValue) + case contains(CodexChatPredicateValue, CodexChatPredicateValue) + case conjunction(CodexChatPredicateSignature, CodexChatPredicateSignature) + case disjunction(CodexChatPredicateSignature, CodexChatPredicateSignature) + case negation(CodexChatPredicateSignature) +} + +private struct CodexThreadServerFilter: Hashable, Sendable { + private enum ArchiveScope: Equatable { + case unscoped + case scoped(Bool) + case ambiguous + } + + var archived: Bool? + var workspaces: [URL]? + var searchTerm: String? + var modelProviders: [String]? + var sourceKinds: [CodexThreadSourceKind]? + var isComplete = true + + init() {} + + init(signature: CodexChatPredicateSignature) throws { + let derivedFilter = Self.filter(from: signature) + if signature.referencesSourceKind, derivedFilter?.sourceKinds == nil { + throw CodexFetchValidationError.unsupportedPredicate( + String(describing: signature) + ) + } + let archiveScope = Self.archiveScope(from: signature) + guard var filter = derivedFilter else { + switch archiveScope { + case .scoped(let archived): + self = Self(isComplete: false) + self.archived = archived + case .unscoped: + self = Self(isComplete: false) + case .ambiguous: + self = Self(isComplete: false) + } + return + } + switch archiveScope { + case .scoped(let archived): + if let filterArchived = filter.archived, filterArchived != archived { + throw CodexFetchValidationError.invalidArchiveScope( + String(describing: signature) + ) + } + filter.archived = archived + case .unscoped: + break + case .ambiguous: + filter.archived = nil + filter.isComplete = false + } + self = filter + } + + private init(isComplete: Bool) { + self.isComplete = isComplete + } + + static var defaultChatFilter: Self { + var filter = Self() + filter.archived = false + return filter + } + + var requiresServerRefreshForMembership: Bool { + searchTerm?.isEmpty == false + || modelProviders?.isEmpty == false + || isComplete == false + } + + func matchesArchiveScope(_ record: CodexChatRecord) -> Bool { + matchesArchiveScope(record.isArchived) + } + + func matchesArchiveScope(_ archived: Bool) -> Bool { + self.archived.map { archived == $0 } ?? true + } + + private static func archiveScope(from signature: CodexChatPredicateSignature) -> ArchiveScope { + switch signature { + case .bool(.key(.isArchived)): + return .scoped(true) + case .bool: + return .unscoped + case .equal(let lhs, let rhs): + return equalityArchiveScope(lhs, rhs) + case .notEqual(let lhs, let rhs): + return inequalityArchiveScope(lhs, rhs) + case .localizedStandardContains, .contains: + return .unscoped + case .conjunction(let lhs, let rhs): + if lhs.boolConstant == false || rhs.boolConstant == false { + return .unscoped + } + if lhs.boolConstant == true { + return archiveScope(from: rhs) + } + if rhs.boolConstant == true { + return archiveScope(from: lhs) + } + return mergeConjunctionArchiveScope(archiveScope(from: lhs), archiveScope(from: rhs)) + case .disjunction(let lhs, let rhs): + if lhs.boolConstant == true || rhs.boolConstant == true { + return .unscoped + } + if lhs.boolConstant == false { + return archiveScope(from: rhs) + } + if rhs.boolConstant == false { + return archiveScope(from: lhs) + } + return mergeDisjunctionArchiveScope(archiveScope(from: lhs), archiveScope(from: rhs)) + case .negation(let signature): + return negatedArchiveScope(from: signature) + } + } + + private static func equalityArchiveScope( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> ArchiveScope { + switch (lhs, rhs) { + case (.key(.isArchived), .bool(let value)), (.bool(let value), .key(.isArchived)): + return .scoped(value) + default: + return .unscoped + } + } + + private static func inequalityArchiveScope( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> ArchiveScope { + switch (lhs, rhs) { + case (.key(.isArchived), .bool(let value)), (.bool(let value), .key(.isArchived)): + return .scoped(!value) + default: + return .unscoped + } + } + + private static func negatedArchiveScope( + from signature: CodexChatPredicateSignature + ) -> ArchiveScope { + switch signature { + case .bool(.key(.isArchived)): + return .scoped(false) + case .bool: + return .unscoped + case .equal(let lhs, let rhs): + return negatedEqualityArchiveScope(lhs, rhs) + case .notEqual(let lhs, let rhs): + return negatedInequalityArchiveScope(lhs, rhs) + case .localizedStandardContains, .contains: + return .unscoped + case .conjunction(let lhs, let rhs): + if lhs.boolConstant == true { + return negatedArchiveScope(from: rhs) + } + if rhs.boolConstant == true { + return negatedArchiveScope(from: lhs) + } + if lhs.boolConstant == false || rhs.boolConstant == false { + return .unscoped + } + return archiveScope(from: signature) == .unscoped ? .unscoped : .ambiguous + case .disjunction(let lhs, let rhs): + if lhs.boolConstant == false { + return negatedArchiveScope(from: rhs) + } + if rhs.boolConstant == false { + return negatedArchiveScope(from: lhs) + } + if lhs.boolConstant == true || rhs.boolConstant == true { + return .unscoped + } + return archiveScope(from: signature) == .unscoped ? .unscoped : .ambiguous + case .negation(let signature): + return archiveScope(from: signature) + } + } + + private static func negatedEqualityArchiveScope( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> ArchiveScope { + switch equalityArchiveScope(lhs, rhs) { + case .scoped(let archived): + return .scoped(!archived) + case .unscoped: + return .unscoped + case .ambiguous: + return .ambiguous + } + } + + private static func negatedInequalityArchiveScope( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> ArchiveScope { + switch inequalityArchiveScope(lhs, rhs) { + case .scoped(let archived): + return .scoped(!archived) + case .unscoped: + return .unscoped + case .ambiguous: + return .ambiguous + } + } + + private static func mergeConjunctionArchiveScope( + _ lhs: ArchiveScope, + _ rhs: ArchiveScope + ) -> ArchiveScope { + switch (lhs, rhs) { + case (.ambiguous, _), (_, .ambiguous): + return .ambiguous + case (.unscoped, let scope), (let scope, .unscoped): + return scope + case (.scoped(let lhs), .scoped(let rhs)): + return lhs == rhs ? .scoped(lhs) : .ambiguous + } + } + + private static func mergeDisjunctionArchiveScope( + _ lhs: ArchiveScope, + _ rhs: ArchiveScope + ) -> ArchiveScope { + switch (lhs, rhs) { + case (.ambiguous, _), (_, .ambiguous): + return .ambiguous + case (.unscoped, .unscoped): + return .unscoped + case (.scoped(let lhs), .scoped(let rhs)) where lhs == rhs: + return .scoped(lhs) + default: + return .ambiguous + } + } + + private static func filter(from signature: CodexChatPredicateSignature) -> Self? { + switch signature { + case .bool(.key(.isArchived)): + var filter = Self() + filter.archived = true + return filter + case .bool(.bool(let value)): + return Self(isComplete: value) + case .bool: + return nil + case .negation(.bool(.key(.isArchived))): + var filter = Self() + filter.archived = false + return filter + case .negation(.bool(.bool(let value))): + return Self(isComplete: value == false) + case .negation: + return nil + case .equal(let lhs, let rhs): + return equalityFilter(lhs, rhs) + case .notEqual(let lhs, let rhs): + return inequalityFilter(lhs, rhs) + case .localizedStandardContains(let lhs, let rhs): + return localizedContainsFilter(lhs, rhs) + case .contains(let lhs, let rhs): + return containsFilter(lhs, rhs) + case .conjunction(let lhs, let rhs): + if lhs.boolConstant == true { + return filter(from: rhs) + } + if rhs.boolConstant == true { + return filter(from: lhs) + } + if lhs.boolConstant == false || rhs.boolConstant == false { + return Self(isComplete: false) + } + guard var lhsFilter = filter(from: lhs), + let rhsFilter = filter(from: rhs), + lhsFilter.merge(rhsFilter) + else { + return nil + } + return lhsFilter + case .disjunction(let lhs, let rhs): + if lhs.boolConstant == false { + return filter(from: rhs) + } + if rhs.boolConstant == false { + return filter(from: lhs) + } + if lhs.boolConstant == true || rhs.boolConstant == true { + return Self() + } + return disjunctionFilter(lhs, rhs) + } + } + + private mutating func merge(_ other: Self) -> Bool { + guard merge(&archived, other.archived), + merge(&workspaces, other.workspaces), + merge(&searchTerm, other.searchTerm), + merge(&modelProviders, other.modelProviders), + merge(&sourceKinds, other.sourceKinds) + else { + return false + } + isComplete = isComplete && other.isComplete + return true + } + + private func merge(_ lhs: inout Value?, _ rhs: Value?) -> Bool { + guard let rhs else { + return true + } + guard let lhsValue = lhs else { + lhs = rhs + return true + } + return lhsValue == rhs + } + + private static func equalityFilter( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> Self? { + switch (lhs, rhs) { + case (.key(.isArchived), .bool(let value)), (.bool(let value), .key(.isArchived)): + var filter = Self() + filter.archived = value + return filter + case (.key(.workspaceID), .optionalWorkspaceID(.some(let id))), + (.optionalWorkspaceID(.some(let id)), .key(.workspaceID)), + (.key(.workspaceID), .workspaceID(let id)), + (.workspaceID(let id), .key(.workspaceID)): + var filter = Self() + filter.workspaces = [URL(fileURLWithPath: id.rawValue, isDirectory: true)] + return filter + case (.key(.modelProvider), .optionalString(.some(let provider))), + (.optionalString(.some(let provider)), .key(.modelProvider)), + (.key(.modelProvider), .string(let provider)), + (.string(let provider), .key(.modelProvider)): + var filter = Self() + filter.modelProviders = [provider] + return filter + case (.key(.sourceKind), .optionalSourceKind(.some(let sourceKind))), + (.optionalSourceKind(.some(let sourceKind)), .key(.sourceKind)), + (.key(.sourceKind), .sourceKind(let sourceKind)), + (.sourceKind(let sourceKind), .key(.sourceKind)): + var filter = Self() + filter.sourceKinds = [sourceKind] + filter.isComplete = sourceKind != .subAgent + return filter + default: + return nilCheckFilter(lhs, rhs) + } + } + + private static func inequalityFilter( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> Self? { + switch (lhs, rhs) { + case (.key(.isArchived), .bool(let value)), (.bool(let value), .key(.isArchived)): + var filter = Self() + filter.archived = !value + return filter + default: + return nilCheckFilter(lhs, rhs) + } + } + + private static func nilCheckFilter( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> Self? { + switch (lhs, rhs) { + case (.key(.workspaceID), .nilLiteral), + (.nilLiteral, .key(.workspaceID)), + (.key(.modelProvider), .nilLiteral), + (.nilLiteral, .key(.modelProvider)), + (.key(.sourceKind), .nilLiteral), + (.nilLiteral, .key(.sourceKind)): + return Self(isComplete: false) + default: + return nil + } + } + + private static func localizedContainsFilter( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> Self? { + guard lhs == .key(.searchableText), + case .string(let searchTerm) = rhs + else { + return nil + } + return searchTerm.isEmpty ? Self() : Self(isComplete: false) + } + + private static func containsFilter( + _ lhs: CodexChatPredicateValue, + _ rhs: CodexChatPredicateValue + ) -> Self? { + switch (lhs, rhs) { + case (.stringArray(let values), .key(.modelProvider)): + guard values.isEmpty == false else { + return Self(isComplete: false) + } + var filter = Self() + filter.modelProviders = values + return filter + case (.sourceKindArray(let values), .key(.sourceKind)): + guard values.isEmpty == false else { + return Self(isComplete: false) + } + var filter = Self() + filter.sourceKinds = values + filter.isComplete = values.contains(.subAgent) == false + return filter + case (.workspaceIDArray(let values), .key(.workspaceID)): + guard values.isEmpty == false else { + return Self(isComplete: false) + } + var filter = Self() + filter.workspaces = values.map { URL(fileURLWithPath: $0.rawValue, isDirectory: true) } + return filter + default: + return nil + } + } + + private static func disjunctionFilter( + _ lhs: CodexChatPredicateSignature, + _ rhs: CodexChatPredicateSignature + ) -> Self? { + guard let lhs = filter(from: lhs), let rhs = filter(from: rhs) else { + return nil + } + var merged = Self(isComplete: lhs.isComplete && rhs.isComplete) + if lhs.onlyHasSourceKinds, rhs.onlyHasSourceKinds { + merged.sourceKinds = union(lhs.sourceKinds, rhs.sourceKinds) + return merged + } + if lhs.onlyHasModelProviders, rhs.onlyHasModelProviders { + merged.modelProviders = union(lhs.modelProviders, rhs.modelProviders) + return merged + } + if lhs.onlyHasWorkspaces, rhs.onlyHasWorkspaces { + merged.workspaces = union(lhs.workspaces, rhs.workspaces) + return merged + } + return nil + } + + private var onlyHasSourceKinds: Bool { + sourceKinds != nil && archived == nil && workspaces == nil + && searchTerm == nil && modelProviders == nil + } + + private var onlyHasModelProviders: Bool { + modelProviders != nil && archived == nil && workspaces == nil + && searchTerm == nil && sourceKinds == nil + } + + private var onlyHasWorkspaces: Bool { + workspaces != nil && archived == nil && searchTerm == nil + && modelProviders == nil && sourceKinds == nil + } + + private static func union(_ lhs: [Value]?, _ rhs: [Value]?) -> [Value]? { + let values = (lhs ?? []) + (rhs ?? []) + var seen: Set = [] + let unique = values.filter { seen.insert($0).inserted } + return unique.isEmpty ? nil : unique + } +} + +private struct CodexChatExpression: Sendable { + var evaluate: @Sendable (CodexChatRecord) -> Value + var signature: CodexChatPredicateValue +} + +private enum CodexChatSequenceValue: Sendable { + case strings([String]) + case workspaceIDs([CodexWorkspaceID]) + case sourceKinds([CodexThreadSourceKind]) + + func contains(_ value: CodexChatPredicateValue) -> Bool { + switch (self, value) { + case (.strings(let values), .string(let value)): + values.contains(value) + case (.workspaceIDs(let values), .workspaceID(let value)): + values.contains(value) + case (.sourceKinds(let values), .sourceKind(let value)): + values.contains(value) + default: + false + } + } +} + +private struct CodexChatPredicateLowering: Sendable { + var predicate: CodexThreadQueryPlan.RecordPredicate + var signature: CodexChatPredicateSignature +} + +private extension CodexChatPredicateSignature { + var boolConstant: Bool? { + guard case .bool(.bool(let value)) = self else { + return nil + } + return value + } + + var referencesSourceKind: Bool { + switch self { + case .bool(let value): + value == .key(.sourceKind) + case .equal(let lhs, let rhs), + .notEqual(let lhs, let rhs), + .localizedStandardContains(let lhs, let rhs), + .contains(let lhs, let rhs): + lhs == .key(.sourceKind) || rhs == .key(.sourceKind) + case .conjunction(let lhs, let rhs), .disjunction(let lhs, let rhs): + lhs.referencesSourceKind || rhs.referencesSourceKind + case .negation(let predicate): + predicate.referencesSourceKind + } + } +} + +extension PredicateExpressions.Value: CodexChatRecordPredicateExpression where Output == Bool { + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let expression = try codexChatBoolExpression() + return .init(predicate: expression.evaluate, signature: .bool(expression.signature)) + } +} + +private protocol CodexChatRecordPredicateExpression { + func codexChatRecordPredicate() throws -> CodexChatPredicateLowering +} + +private protocol CodexChatRecordBoolExpression { + func codexChatBoolExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordStringExpression { + func codexChatStringExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordOptionalStringExpression { + func codexChatOptionalStringExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordWorkspaceIDExpression { + func codexChatWorkspaceIDExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordOptionalWorkspaceIDExpression { + func codexChatOptionalWorkspaceIDExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordSourceKindExpression { + func codexChatSourceKindExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordOptionalSourceKindExpression { + func codexChatOptionalSourceKindExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordEquatableExpression { + func codexChatEquatableExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordSequenceExpression { + func codexChatSequenceExpression() throws -> CodexChatExpression +} + +private protocol CodexChatRecordMembershipElementExpression { + func codexChatMembershipElementExpression() throws -> CodexChatExpression +} + +private func makeCodexChatRecordPredicate( + _ predicate: Predicate +) throws -> CodexChatPredicateLowering { + guard let expression = predicate.expression as? any CodexChatRecordPredicateExpression else { + throw CodexFetchValidationError.unsupportedPredicate( + String(reflecting: type(of: predicate.expression)) + ) + } + return try expression.codexChatRecordPredicate() +} + +extension PredicateExpressions.Conjunction: CodexChatRecordPredicateExpression + where LHS: CodexChatRecordPredicateExpression, RHS: CodexChatRecordPredicateExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let lhsPredicate = try lhs.codexChatRecordPredicate() + let rhsPredicate = try rhs.codexChatRecordPredicate() + return .init( + predicate: { record in + lhsPredicate.predicate(record) && rhsPredicate.predicate(record) + }, + signature: .conjunction(lhsPredicate.signature, rhsPredicate.signature) + ) + } +} + +extension PredicateExpressions.Disjunction: CodexChatRecordPredicateExpression + where LHS: CodexChatRecordPredicateExpression, RHS: CodexChatRecordPredicateExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let lhsPredicate = try lhs.codexChatRecordPredicate() + let rhsPredicate = try rhs.codexChatRecordPredicate() + return .init( + predicate: { record in + lhsPredicate.predicate(record) || rhsPredicate.predicate(record) + }, + signature: .disjunction(lhsPredicate.signature, rhsPredicate.signature) + ) + } +} + +extension PredicateExpressions.Negation: CodexChatRecordPredicateExpression + where Wrapped: CodexChatRecordPredicateExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let predicate = try wrapped.codexChatRecordPredicate() + return .init( + predicate: { record in + predicate.predicate(record) == false + }, + signature: .negation(predicate.signature) + ) + } +} + +extension PredicateExpressions.Equal: CodexChatRecordPredicateExpression + where LHS: CodexChatRecordEquatableExpression, RHS: CodexChatRecordEquatableExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let lhsExpression = try lhs.codexChatEquatableExpression() + let rhsExpression = try rhs.codexChatEquatableExpression() + return .init( + predicate: { record in + lhsExpression.evaluate(record) + .codexPredicateEquals(rhsExpression.evaluate(record)) + }, + signature: .equal(lhsExpression.signature, rhsExpression.signature) + ) + } +} + +extension PredicateExpressions.NotEqual: CodexChatRecordPredicateExpression + where LHS: CodexChatRecordEquatableExpression, RHS: CodexChatRecordEquatableExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let lhsExpression = try lhs.codexChatEquatableExpression() + let rhsExpression = try rhs.codexChatEquatableExpression() + return .init( + predicate: { record in + lhsExpression.evaluate(record) + .codexPredicateEquals(rhsExpression.evaluate(record)) == false + }, + signature: .notEqual(lhsExpression.signature, rhsExpression.signature) + ) + } +} + +extension PredicateExpressions.StringLocalizedStandardContains: CodexChatRecordPredicateExpression + where Root: CodexChatRecordStringExpression, Other: CodexChatRecordStringExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let rootExpression = try root.codexChatStringExpression() + let otherExpression = try other.codexChatStringExpression() + return .init( + predicate: { record in + rootExpression.evaluate(record).localizedStandardContains(otherExpression.evaluate(record)) + }, + signature: .localizedStandardContains(rootExpression.signature, otherExpression.signature) + ) + } +} + +extension PredicateExpressions.SequenceContains: CodexChatRecordPredicateExpression + where LHS: CodexChatRecordSequenceExpression, RHS: CodexChatRecordMembershipElementExpression +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let sequenceExpression = try sequence.codexChatSequenceExpression() + let elementExpression = try element.codexChatMembershipElementExpression() + return .init( + predicate: { record in + sequenceExpression.evaluate(record).contains(elementExpression.evaluate(record)) + }, + signature: .contains(sequenceExpression.signature, elementExpression.signature) + ) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordBoolExpression + where Root == PredicateExpressions.Variable, Output == Bool +{ + fileprivate func codexChatBoolExpression() throws -> CodexChatExpression { + if keyPath == \CodexChat.isArchived { + return .init(evaluate: { $0.isArchived }, signature: .key(.isArchived)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordPredicateExpression + where Root == PredicateExpressions.Variable, Output == Bool +{ + fileprivate func codexChatRecordPredicate() throws -> CodexChatPredicateLowering { + let expression = try codexChatBoolExpression() + return .init(predicate: expression.evaluate, signature: .bool(expression.signature)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordStringExpression + where Root == PredicateExpressions.Variable, Output == String +{ + fileprivate func codexChatStringExpression() throws -> CodexChatExpression { + if keyPath == \CodexChat.searchableText { + return .init(evaluate: { $0.searchableText }, signature: .key(.searchableText)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordOptionalStringExpression + where Root == PredicateExpressions.Variable, Output == String? +{ + fileprivate func codexChatOptionalStringExpression() throws -> CodexChatExpression { + if keyPath == \CodexChat.modelProvider { + return .init(evaluate: { $0.modelProvider }, signature: .key(.modelProvider)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordOptionalWorkspaceIDExpression + where Root == PredicateExpressions.Variable, Output == CodexWorkspaceID? +{ + fileprivate func codexChatOptionalWorkspaceIDExpression() throws -> CodexChatExpression { + if keyPath == \CodexChat.workspaceID { + return .init(evaluate: { $0.workspaceID }, signature: .key(.workspaceID)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordOptionalSourceKindExpression + where Root == PredicateExpressions.Variable, Output == CodexThreadSourceKind? +{ + fileprivate func codexChatOptionalSourceKindExpression() throws -> CodexChatExpression { + if keyPath == \CodexChat.sourceKind { + return .init(evaluate: { $0.sourceKind }, signature: .key(.sourceKind)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.KeyPath: CodexChatRecordEquatableExpression + where Root == PredicateExpressions.Variable +{ + fileprivate func codexChatEquatableExpression() throws -> CodexChatExpression { + if Output.self == Bool.self { + let expression = try (self as! PredicateExpressions.KeyPath) + .codexChatBoolExpression() + return .init( + evaluate: { .bool(expression.evaluate($0)) }, + signature: expression.signature + ) + } + if Output.self == String?.self { + let expression = try (self as! PredicateExpressions.KeyPath) + .codexChatOptionalStringExpression() + return .init( + evaluate: { .optionalString(expression.evaluate($0)) }, + signature: expression.signature + ) + } + if Output.self == CodexWorkspaceID?.self { + let expression = try (self as! PredicateExpressions.KeyPath) + .codexChatOptionalWorkspaceIDExpression() + return .init( + evaluate: { .optionalWorkspaceID(expression.evaluate($0)) }, + signature: expression.signature + ) + } + if Output.self == CodexThreadSourceKind?.self { + let expression = try (self as! PredicateExpressions.KeyPath) + .codexChatOptionalSourceKindExpression() + return .init( + evaluate: { .optionalSourceKind(expression.evaluate($0)) }, + signature: expression.signature + ) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: keyPath)) + } +} + +extension PredicateExpressions.ForcedUnwrap: CodexChatRecordStringExpression + where Inner: CodexChatRecordOptionalStringExpression, Wrapped == String +{ + fileprivate func codexChatStringExpression() throws -> CodexChatExpression { + let expression = try inner.codexChatOptionalStringExpression() + return .init( + evaluate: { record in + guard let value = expression.evaluate(record) else { + preconditionFailure("CodexChat predicate force-unwrapped nil String.") + } + return value + }, + signature: expression.signature + ) + } +} + +extension PredicateExpressions.ForcedUnwrap: CodexChatRecordMembershipElementExpression + where Inner: CodexChatRecordEquatableExpression +{ + fileprivate func codexChatMembershipElementExpression() throws -> CodexChatExpression { + let expression = try inner.codexChatEquatableExpression() + return .init( + evaluate: { record in + switch expression.evaluate(record) { + case .optionalString(.some(let value)): + return .string(value) + case .optionalWorkspaceID(.some(let value)): + return .workspaceID(value) + case .optionalSourceKind(.some(let value)): + return .sourceKind(value) + case .optionalString(.none), + .optionalWorkspaceID(.none), + .optionalSourceKind(.none): + return .nilLiteral("membership") + default: + preconditionFailure("CodexChat predicate force-unwrapped an unsupported or nil membership value.") + } + }, + signature: expression.signature + ) + } +} + +extension PredicateExpressions.ForcedUnwrap: CodexChatRecordWorkspaceIDExpression + where Inner: CodexChatRecordOptionalWorkspaceIDExpression, Wrapped == CodexWorkspaceID +{ + fileprivate func codexChatWorkspaceIDExpression() throws -> CodexChatExpression { + let expression = try inner.codexChatOptionalWorkspaceIDExpression() + return .init( + evaluate: { record in + guard let value = expression.evaluate(record) else { + preconditionFailure("CodexChat predicate force-unwrapped nil workspace ID.") + } + return value + }, + signature: expression.signature + ) + } +} + +extension PredicateExpressions.ForcedUnwrap: CodexChatRecordSourceKindExpression + where Inner: CodexChatRecordOptionalSourceKindExpression, Wrapped == CodexThreadSourceKind +{ + fileprivate func codexChatSourceKindExpression() throws -> CodexChatExpression { + let expression = try inner.codexChatOptionalSourceKindExpression() + return .init( + evaluate: { record in + guard let value = expression.evaluate(record) else { + preconditionFailure("CodexChat predicate force-unwrapped nil source kind.") + } + return value + }, + signature: expression.signature + ) + } +} + +extension PredicateExpressions.Value: CodexChatRecordBoolExpression where Output == Bool { + fileprivate func codexChatBoolExpression() throws -> CodexChatExpression { + let value = value + return .init(evaluate: { _ in value }, signature: .bool(value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordStringExpression where Output == String { + fileprivate func codexChatStringExpression() throws -> CodexChatExpression { + let value = value + return .init(evaluate: { _ in value }, signature: .string(value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordOptionalStringExpression + where Output == String? +{ + fileprivate func codexChatOptionalStringExpression() throws -> CodexChatExpression { + let value = value + return .init(evaluate: { _ in value }, signature: .optionalString(value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordOptionalWorkspaceIDExpression + where Output == CodexWorkspaceID? +{ + fileprivate func codexChatOptionalWorkspaceIDExpression() throws -> CodexChatExpression { + let value = value + return .init(evaluate: { _ in value }, signature: .optionalWorkspaceID(value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordOptionalSourceKindExpression + where Output == CodexThreadSourceKind? +{ + fileprivate func codexChatOptionalSourceKindExpression() throws -> CodexChatExpression { + let value = value + return .init(evaluate: { _ in value }, signature: .optionalSourceKind(value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordEquatableExpression { + fileprivate func codexChatEquatableExpression() throws -> CodexChatExpression { + if Output.self == Bool.self { + let value = value as! Bool + return .init(evaluate: { _ in .bool(value) }, signature: .bool(value)) + } + if Output.self == String?.self { + let value = value as! String? + return .init(evaluate: { _ in .optionalString(value) }, signature: .optionalString(value)) + } + if Output.self == String.self { + let value = value as! String + return .init(evaluate: { _ in .string(value) }, signature: .string(value)) + } + if Output.self == CodexWorkspaceID?.self { + let value = value as! CodexWorkspaceID? + return .init( + evaluate: { _ in .optionalWorkspaceID(value) }, + signature: .optionalWorkspaceID(value) + ) + } + if Output.self == CodexWorkspaceID.self { + let value = value as! CodexWorkspaceID + return .init(evaluate: { _ in .workspaceID(value) }, signature: .workspaceID(value)) + } + if Output.self == CodexThreadSourceKind?.self { + let value = value as! CodexThreadSourceKind? + return .init( + evaluate: { _ in .optionalSourceKind(value) }, + signature: .optionalSourceKind(value) + ) + } + if Output.self == CodexThreadSourceKind.self { + let value = value as! CodexThreadSourceKind + return .init(evaluate: { _ in .sourceKind(value) }, signature: .sourceKind(value)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: value)) + } +} + +extension PredicateExpressions.Value: CodexChatRecordSequenceExpression { + fileprivate func codexChatSequenceExpression() throws -> CodexChatExpression { + if let value = value as? [String] { + return .init(evaluate: { _ in .strings(value) }, signature: .stringArray(value)) + } + if let value = value as? [CodexWorkspaceID] { + return .init(evaluate: { _ in .workspaceIDs(value) }, signature: .workspaceIDArray(value)) + } + if let value = value as? [CodexThreadSourceKind] { + return .init(evaluate: { _ in .sourceKinds(value) }, signature: .sourceKindArray(value)) + } + throw CodexFetchValidationError.unsupportedPredicate(String(describing: value)) + } +} + +extension PredicateExpressions.NilLiteral: CodexChatRecordEquatableExpression { + fileprivate func codexChatEquatableExpression() throws -> CodexChatExpression { + .init( + evaluate: { _ in .nilLiteral(String(describing: Wrapped.self)) }, + signature: .nilLiteral(String(describing: Wrapped.self)) + ) + } +} diff --git a/Sources/CodexDataKit/FetchedResultsLoadCoordinator.swift b/Sources/CodexDataKit/FetchedResultsLoadCoordinator.swift new file mode 100644 index 00000000..fbc8ef53 --- /dev/null +++ b/Sources/CodexDataKit/FetchedResultsLoadCoordinator.swift @@ -0,0 +1,122 @@ +import Foundation +import Synchronization + +package final class FetchedResultsLoadCoordinator: Sendable { + private struct Waiter { + var id: UUID + var continuation: CheckedContinuation + } + + private struct State { + var activeID: UUID? + var waiters: [Waiter] = [] + var cancelledBeforeRegistration: Set = [] + var pendingLoadWaiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + + package init() {} + + package func waitUntilPendingLoad() async { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + guard state.waiters.isEmpty else { + state.pendingLoadWaiters.append(continuation) + return false + } + return true + } + if shouldResume { + continuation.resume() + } + } + } + + package func withPermit( + _ operation: () async throws -> Result + ) async throws -> Result { + let id = UUID() + try await acquire(id: id) + defer { + release(id: id) + } + try Task.checkCancellation() + return try await operation() + } + + private func acquire(id: UUID) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let result: (ResumeAction, [CheckedContinuation]) = state.withLock { + state in + if state.cancelledBeforeRegistration.remove(id) != nil { + return (.cancelled(continuation), []) + } + guard state.activeID != nil else { + state.activeID = id + return (.acquired(continuation), []) + } + state.waiters.append(Waiter(id: id, continuation: continuation)) + let pendingLoadWaiters = state.pendingLoadWaiters + state.pendingLoadWaiters.removeAll(keepingCapacity: false) + return (.pending, pendingLoadWaiters) + } + result.0.resume() + for waiter in result.1 { + waiter.resume() + } + } + } onCancel: { + cancel(id: id) + } + } + + private func cancel(id: UUID) { + let continuation: CheckedContinuation? = state.withLock { state in + if state.activeID == id { + return nil + } + if let index = state.waiters.firstIndex(where: { $0.id == id }) { + return state.waiters.remove(at: index).continuation + } + state.cancelledBeforeRegistration.insert(id) + return nil + } + continuation?.resume(throwing: CancellationError()) + } + + private func release(id: UUID) { + let continuation: CheckedContinuation? = state.withLock { state in + precondition( + state.activeID == id, + "FetchedResultsLoadCoordinator released a non-active permit." + ) + guard state.waiters.isEmpty == false else { + state.activeID = nil + return nil + } + let waiter = state.waiters.removeFirst() + state.activeID = waiter.id + return waiter.continuation + } + continuation?.resume() + } + + private enum ResumeAction { + case acquired(CheckedContinuation) + case cancelled(CheckedContinuation) + case pending + + func resume() { + switch self { + case .acquired(let continuation): + continuation.resume() + case .cancelled(let continuation): + continuation.resume(throwing: CancellationError()) + case .pending: + break + } + } + } +} diff --git a/Sources/CodexDataKit/README.md b/Sources/CodexDataKit/README.md new file mode 100644 index 00000000..e3d949c5 --- /dev/null +++ b/Sources/CodexDataKit/README.md @@ -0,0 +1,346 @@ +# CodexDataKit + +CodexDataKit provides SwiftData-style `@Observable` app-server backed models on top of `CodexAppServerKit`. + +Use this package when app or UI code needs workspace group, workspace, and chat models without rendering directly from JSON-RPC payloads. + +## Main Types + +- `CodexModelContainer`: Associates a `CodexAppServer` with an eagerly created main-actor `CodexModelContext`. +- `CodexModelContext`: Fetches models, preserves model identity, and performs app-server actions for attached models. +- `CodexModelActor`, `CodexDefaultSerialModelExecutor`: Own a separate context graph on a serial model-actor executor. +- `CodexFetchDescriptor`: Value description of predicate, sort order, limit, offset, and context-change inclusion. +- `CodexFetchedResults`: The observable owner of query criteria, items, optional sections, cursors, typed phase, identity snapshot, and ordered transactions. +- `CodexFetchedResultsSnapshot`, `CodexFetchedResultsTransaction`: Section and item ID snapshots plus section/item changes suitable for conversion to native UI update APIs. +- `CodexPersistentModel`: SwiftData-style model protocol. The protocol itself is not main-actor isolated; concrete context ownership decides the isolation domain. +- `CodexWorkspaceGroup`, `CodexWorkspace`, `CodexChat`, `CodexTurn`, `CodexItem`: Observable model objects attached to a model context. +- `CodexQuery`: A SwiftUI `DynamicProperty` wrapper around `CodexFetchedResults`. +- `CodexFetchPhase`: The typed fetch state. Validation and app-server failures are carried by `CodexFetchFailure`. + +## Quick Start + +```swift +import CodexAppServerKit +import CodexDataKit + +let appServer = try await CodexAppServer() +let container = CodexModelContainer(appServer: appServer) +let context = container.mainContext + +let chats = context.fetchedResults(for: CodexFetchDescriptor.recentChats) +try await chats.performFetch() + +for chat in chats.items { + print(chat.title) +} + +await appServer.close() +``` + +The container does not take app-server close authority. `init(appServer:)` associates +a caller-managed server, and the owner that created that server remains responsible +for closing it. Internally, contexts retain a context-family coordinator rather than +the container facade, so retaining a context does not create a container/main-context +cycle or silently lose cross-context delivery. + +## Fetching + +Use `CodexFetchDescriptor` as the canonical query value: + +```swift +let workspaceID = CodexWorkspaceID(rawValue: workspaceURL.standardizedFileURL.resolvingSymlinksInPath().path) +let descriptor = CodexFetchDescriptor( + predicate: #Predicate { chat in + chat.isArchived == false + && chat.workspaceID == workspaceID + && chat.searchableText.localizedStandardContains("review") + }, + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 50 +) + +let results = context.fetchedResults( + for: descriptor, + sectionedBy: .workspaceGroup +) +try await results.performFetch() +``` + +Sort descriptors use the known key-path contract directly. A key path must map to a +supported CodexDataKit model field; arbitrary key paths are not silently treated as +app-server sorts. Section descriptors support the same key-path style, plus +relationship aliases such as `.workspaceGroup` and `.workspace` for the common +sidebar groupings. Unsupported predicate, sort, section, model, limit, and offset +values fail through `CodexFetchFailure.validation` instead of trapping during query +construction or SwiftUI updates. + +A nil chat predicate is the active-only convenience scope. An explicit predicate is +evaluated literally: if it does not mention `isArchived`, CodexDataKit fetches and +merges both active and archived server scopes. Add `chat.isArchived == false` when a +consumer wants active chats only. + +Chat fetches include eligible context changes by default. A `CodexChat` created or actively observed by the +context remains eligible for fetch results when the app-server thread list +temporarily omits it, as long as the predicate can be decided locally. Set +`includeContextChanges` to `false` on `CodexFetchDescriptor` +when a fetch should report only the server-owned page membership. + +Every effective local ordering ends with the model's typed ID in the primary sort +direction. The pinned app-server's `createdAt` and `updatedAt` cursors do not include +that tie-breaker, so those sorts enumerate the server through its stable `recencyAt` +cursor and then sort/page locally. A source-unconstrained fetch composes the +app-server's interactive-default listing with explicit user-visible noninteractive +source kinds; internal memory-consolidation sessions are not part of that default +membership. Because those partitions have no single server order, an empty `sortBy` +reconstructs the app-server default as `createdAt` descending with an ID tie-breaker +and pages locally after the merge. A complete query with one archived scope, one +primary `recencyAt` sort, and a positive fetch limit reads bounded prefixes from both +partitions before merging the requested page. Other composite orderings and +unbounded or incomplete queries enumerate the required partitions before local +sorting and paging. An explicit source predicate remains one source partition; it +uses direct server paging only when the predicate is complete, has one archived +scope, and uses empty sorting or one primary `recencyAt` sort. + +Fetches preserve object identity. If the same app-server thread appears in a later refresh, CodexDataKit mutates the existing `CodexChat` instance instead of replacing it. + +```swift +try await results.refresh() + +if results.nextCursor != nil { + try await results.loadNextPage() +} +``` + +Each `CodexFetchedResults` serializes fetch, refresh, pagination, and mutation-driven +reloads. A refresh rebuilds the currently loaded window in staging and commits items, +cursors, sections, and phase together. Cancelling a queued load removes it from the +queue; cancelling an in-flight load preserves the prior stable result and phase. +Queries stay live for mutations performed through the same model context. Changes +made by another process or app-server client require an explicit `refresh()`. + +Use `registeredModel(for:)` when code needs only models that are already registered in +the context. This lookup does not create placeholder chats and does not issue an +app-server request. + +```swift +if let chat = context.registeredModel(for: threadID) { + render(chat.title) +} +``` + +`model(for: CodexThreadID)` remains the identity/placeholder API: it returns the +registered chat when present, or registers a placeholder `CodexChat` for that ID. +Workspace and workspace-group IDs also support `registeredModel(for:)` for symmetric +context identity lookups. + +## Sectioning + +Pass `sectionedBy` at the results/query boundary when a UI wants sidebar sections. + +```swift +let workspaces = context.fetchedResults( + for: CodexFetchDescriptor.workspaces, + sectionedBy: .workspaceGroup +) + +let chats = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + ), + sectionedBy: .workspace +) +``` + +Passing no section descriptor gives a single unsectioned result. Section identifiers stay typed as `CodexFetchSectionID`, so workspace and workspace-group sections can be used directly in UI selection state. Sectioning is a projection after global sorting, offset, and limit: section order follows each section's first item, and members preserve global relative order. A section key is not silently inserted as the primary sort. + +## Fetched Results Transactions + +Use `CodexFetchedResults` when non-SwiftUI UI code needs ordered changes instead of only the observable current value. + +```swift +let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats, + sectionedBy: .workspaceGroup +) + +Task { + for await transaction in results.transactions { + apply( + oldSnapshot: transaction.oldSnapshot, + newSnapshot: transaction.newSnapshot, + sectionChanges: transaction.sectionChanges, + itemChanges: transaction.itemChanges + ) + } +} + +try await results.performFetch() +``` + +`CodexFetchedResults` is the single owner of both current values and transactions; there is no forwarding controller or second model graph. Snapshots contain section IDs, optional titles, and item IDs only; section and item changes are ordered and include insert, delete, move, and update cases. The transaction stream buffers only the newest transaction. Every transaction carries complete old and new identity snapshots, so a consumer whose current snapshot no longer equals `oldSnapshot` replaces it with `newSnapshot` instead of replaying stale granular changes. + +CodexDataKit does not import AppKit, UIKit, or SwiftUI for this API. Convert `CodexFetchedResultsTransaction` into `NSCollectionView`, `UICollectionView`, diffable data source, or `NSOutlineView` updates in the UI layer. Detail transcript streams remain the responsibility of `CodexChat.observe()`. + +## Models + +Models are context-attached observable objects: + +```swift +let workspace = try await context.fetch(CodexFetchDescriptor.workspaces).first +let chat = try await workspace?.startChat() +try await chat?.send("Explain the latest diff.") +``` + +Attached models expose their context for identity and model operations. App-server +lifecycle remains owned by the composition root, not by a model: + +```swift +let sameContext = chat?.modelContext === context +print(sameContext) +``` + +Each CodexDataKit model graph is owned by the context that vended it. The container +eagerly creates its UI-facing `mainContext`, similar to SwiftData's +`ModelContainer.mainContext`. A `CodexModelActor` creates a separate context through +`CodexDefaultSerialModelExecutor`; its actor-isolated `modelContext` is the only +mutation entry point for that graph. + +```swift +actor IndexWorker: CodexModelActor { + nonisolated let modelContainer: CodexModelContainer + nonisolated let modelExecutor: CodexDefaultSerialModelExecutor + + init(container: CodexModelContainer) { + modelContainer = container + modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: container) + } + + func recentChatIDs() async throws -> [CodexThreadID] { + try await modelContext.fetch( + CodexFetchDescriptor.recentChats + ).map(\.id) + } +} +``` + +Treat these instances like Core Data or SwiftData model objects: keep them inside +their vending context, mutate same-identity objects in place, and pass semantic IDs +or value DTOs across concurrency domains. Main and model-actor contexts own distinct +instances and merge supported changes by semantic identity; they never share a +mutable model object. Do not make UI-owned mirrors of model properties just to +observe changes. + +Keep review-specific state, parsed findings, and review timelines outside CodexDataKit. CodexDataKit owns generic Codex app-server data models; higher-level packages can layer their own indices on top of `CodexChat.id`, workspace IDs, or sectioned fetch results. + +When a consumer needs the canonical transcript for one loaded turn, use +`chat.transcript(in:)`. This projection is built by the chat owner from the +context's current items; consumers do not reconstruct `CodexThreadItem` values +or invent a separate output cache. + +CodexDataKit preserves every raw review rollout item while normalizing +the app-server's live and persisted companion assistant representations to +`CodexThreadItem.SemanticRelation.companionOf(.exitedReviewMode)`. Renderers use +that relation instead of inferring review identity from item text, turn +adjacency, or the top-level thread source. Persisted review rollouts may retain +the client source that initiated them. + +## Live Chat Observation + +Use `CodexChat.observe()` or `CodexModelContext.observe(_:)` when a detail view needs an immutable transcript projection followed by live app-server updates. + +```swift +let chat = context.model(for: CodexThreadID(rawValue: "thread-1")) +let observation = try await context.observe(chat) + +for await event in observation.updates { + switch event.payload { + case .snapshot(let snapshot, _): + projection.replace(with: snapshot) + case .update(let update): + projection.apply(update) + } +} + +await observation.close() +``` + +Observation first refreshes or seeds the chat with `includeTurns: true`, then consumes `CodexThread.events`. Turn, item, message, delta, usage, completion, and failure events still mutate the context-owned model graph, but that graph is not the presentation baseline. `CodexItem.id` is the stable model identity; `CodexItem.itemID` keeps the raw app-server item ID. + +Each observation owns one subscriber lease and one iterator. The first event is a complete immutable snapshot. Later events carry a `(generation, sequence)` cursor and either a self-contained update or another complete snapshot barrier. Apply updates only to the immediately preceding projection; a snapshot replaces the projection and covers every event through its cursor. A second consumer must call `observe()` again instead of creating a second iterator from the same `updates` value. + +Item removal and text-append updates use `CodexChatItemLocator`, whose turn ID, item kind, and raw item ID match DataKit's semantic merge key. Do not locate those targets by raw item ID alone because distinct item kinds can legally share that wire ID. + +`CodexChatObservation.chat` remains the context-owned semantic action and identity handle. Do not reread it to apply an update: the graph may already contain later mutations. Keep selection state as semantic IDs and build app-specific presentation state only from event payloads. `CodexChatUpdate.affectedTurnID` can scope update handling after the projection has validated and applied the event cursor. + +Subscriber queues are bounded. A slow subscriber receives a complete `.bufferOverflow` snapshot instead of an unbounded delta backlog. An observation handle retains its context while the handle is alive, so its stream never silently outlives the mutation owner. Explicit `close()` finishes that subscriber and waits for its lease release; closing the last lease also cancels and joins the shared upstream pump. Iterator task cancellation releases the same lease. Deinitialization only signals release and cannot await pump completion, so lifecycle owners should call and await `close()` during normal teardown. + +CodexDataKit may read app-server thread snapshots internally to establish or reconcile the current value. Those reads are not part of the observation stream. Once live events have advanced an observed chat, later thread reads are merged into the existing model and must not rewind already-applied live turns or items unless an explicit model operation such as rollback requests replacement. + +When a higher-level package persists an app-specific operation identity, keep that identity outside CodexDataKit. Resolve it to the app-server thread ID at that layer, then observe the generic chat model: + +```swift +let chat = context.model(for: operation.threadID) +let observation = try await chat.observe() +``` + +## SwiftUI + +Install the container or context in the environment, then use `@CodexQuery` in views. + +```swift +import SwiftUI +import CodexDataKit + +struct Sidebar: View { + @CodexQuery( + sort: \.updatedAt, + order: .reverse, + sectionBy: .workspaceGroup + ) + private var chats + + var body: some View { + List { + ForEach(chats.sections) { section in + Section(section.title ?? "") { + ForEach(section.items) { chat in + Text(chat.title) + } + } + } + } + } +} + +Sidebar() + .codexModelContainer(container) +``` + +## Testing + +Use `CodexAppServerKitTesting` to test CodexDataKit owners without a real app-server process. + +```swift +import CodexAppServerKitTesting +import CodexDataKit +import Testing + +@MainActor +@Test func loadsChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start(threads: []) + + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + + #expect(results.items.isEmpty) + await runtime.close() +} +``` + +Use `CodexAppServerTestStoredThread` when a test needs nonempty results. The +fixture owns the complete current-v2 thread/turn wire value and validates its +`CodexThreadSnapshot` projection instead of re-encoding production models. + +For lower-level app-server APIs, see [../CodexAppServerKit/README.md](../CodexAppServerKit/README.md). diff --git a/Tests/CodexAppServerKitTests/AppServerNotificationDecoderTests.swift b/Tests/CodexAppServerKitTests/AppServerNotificationDecoderTests.swift new file mode 100644 index 00000000..d326b251 --- /dev/null +++ b/Tests/CodexAppServerKitTests/AppServerNotificationDecoderTests.swift @@ -0,0 +1,520 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit +import CodexAppServerKitTesting + +@Suite("AppServerNotificationDecoder") +struct AppServerNotificationDecoderTests { + @Test func currentV2MapperPreservesCanonicalFileChangePathsAndDiffs() throws { + let fixture = try CodexAppServerTestItem.fileChange( + id: "file-change-1", + changes: [ + .init( + path: "Sources/First.swift", + kind: .update(movePath: "Sources/Renamed.swift"), + diff: "@@ -1 +1 @@\n-old\n+new" + ), + .init( + path: "Sources/Second.swift", + kind: .add, + diff: "@@ -0,0 +1 @@\n+second" + ), + ], + status: .completed + ) + let wireData = try JSONEncoder().encode(fixture.wireValue) + let wireValue = try JSONDecoder().decode(AppServerJSONValue.self, from: wireData) + + let mapped = try #require(AppServerThreadItemMapping.threadItem(from: wireValue)) + + #expect(mapped.id == fixture.domainProjection.id) + #expect(mapped.kind == fixture.domainProjection.kind) + #expect(mapped.content == fixture.domainProjection.content) + } + + @Test func currentV2MapperDoesNotSynthesizeOutputForEmptyFileChanges() throws { + let mapped = try #require(AppServerThreadItemMapping.threadItem(from: .object([ + "id": .string("file-change-empty"), + "type": .string("fileChange"), + "changes": .array([]), + "status": .string("inProgress"), + ]))) + + guard case .fileChange(let fileChange) = mapped.content else { + Issue.record("Expected a file-change item.") + return + } + #expect(fileChange.path == nil) + #expect(fileChange.output == nil) + #expect(fileChange.status == .inProgress) + } + + @Test func currentV2MapperRejectsFileChangeWithoutRequiredChanges() { + #expect(AppServerThreadItemMapping.threadItem(from: .object([ + "id": .string("file-change-missing"), + "type": .string("fileChange"), + "status": .string("inProgress"), + ])) == nil) + } + + @Test func currentV2MapperAssignsReviewRolloutCompanionMetadataOnlyToFixedAgentItem() throws { + let reviewAssistant = try #require(AppServerThreadItemMapping.threadItem(from: .object([ + "id": .string("review_rollout_assistant"), + "type": .string("agentMessage"), + "text": .string("review output"), + ]))) + let sameIDWrongKind = try #require(AppServerThreadItemMapping.threadItem(from: .object([ + "id": .string("review_rollout_assistant"), + "type": .string("plan"), + "text": .string("plan"), + ]))) + let ordinaryAssistant = try #require(AppServerThreadItemMapping.threadItem(from: .object([ + "id": .string("assistant-1"), + "type": .string("agentMessage"), + "text": .string("answer"), + ]))) + + #expect(reviewAssistant.origin == .reviewRolloutAssistant) + #expect(reviewAssistant.semanticRelation == .companionOf(.exitedReviewMode)) + #expect(sameIDWrongKind.origin == .currentV2Item) + #expect(sameIDWrongKind.semanticRelation == nil) + #expect(ordinaryAssistant.origin == .currentV2Item) + #expect(ordinaryAssistant.semanticRelation == nil) + } + + @Test func pinnedNotificationInventoryHasOneDispositionPerMethod() { + let route: Set = [ + "error", + "thread/started", + "thread/status/changed", + "thread/archived", + "thread/deleted", + "thread/unarchived", + "thread/closed", + "thread/name/updated", + "thread/tokenUsage/updated", + "turn/started", + "turn/completed", + "turn/diff/updated", + "turn/plan/updated", + "item/started", + "item/completed", + "item/agentMessage/delta", + "item/plan/delta", + "item/commandExecution/outputDelta", + "item/fileChange/patchUpdated", + "serverRequest/resolved", + "item/mcpToolCall/progress", + "account/updated", + "account/rateLimits/updated", + "account/login/completed", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded", + "item/reasoning/textDelta", + ] + let diagnostic: Set = [ + "warning", + "guardianWarning", + "deprecationNotice", + "configWarning", + "model/rerouted", + "model/verification", + "turn/moderationMetadata", + "model/safetyBuffering/updated", + "windows/worldWritableWarning", + "windowsSandbox/setupCompleted", + ] + let explicitIgnore: Set = [ + "skills/changed", + "thread/goal/updated", + "thread/goal/cleared", + "thread/settings/updated", + "hook/started", + "hook/completed", + "item/autoApprovalReview/started", + "item/autoApprovalReview/completed", + "rawResponseItem/completed", + "command/exec/outputDelta", + "process/outputDelta", + "process/exited", + "item/commandExecution/terminalInteraction", + "item/fileChange/outputDelta", + "mcpServer/oauthLogin/completed", + "mcpServer/startupStatus/updated", + "app/list/updated", + "remoteControl/status/changed", + "externalAgentConfig/import/progress", + "externalAgentConfig/import/completed", + "fs/changed", + "thread/compacted", + "fuzzyFileSearch/sessionUpdated", + "fuzzyFileSearch/sessionCompleted", + "thread/realtime/started", + "thread/realtime/itemAdded", + "thread/realtime/transcript/delta", + "thread/realtime/transcript/done", + "thread/realtime/outputAudio/delta", + "thread/realtime/sdp", + "thread/realtime/error", + "thread/realtime/closed", + ] + + let methods = AppServerNotificationDecoder.Method.allCases + #expect(methods.count == 69) + #expect(Set(methods.filter { $0.disposition == .route }.map(\.rawValue)) == route) + #expect(Set(methods.filter { $0.disposition == .diagnostic }.map(\.rawValue)) == diagnostic) + #expect(Set(methods.filter { $0.disposition == .explicitIgnore }.map(\.rawValue)) + == explicitIgnore) + #expect(route.isDisjoint(with: diagnostic)) + #expect(route.isDisjoint(with: explicitIgnore)) + #expect(diagnostic.isDisjoint(with: explicitIgnore)) + } + + @Test func requiredFieldsAndClosedStatusesFailAsMalformedNotifications() throws { + let decoder = AppServerNotificationDecoder() + + try expectMalformed(method: "thread/status/changed") { + try decoder.decode(notification( + method: "thread/status/changed", + json: #"{"threadId":"thread-1"}"# + )) + } + try expectMalformed(method: "item/started") { + try decoder.decode(notification( + method: "item/started", + json: #"{"threadId":"thread-1","turnId":"turn-1","startedAtMs":1,"item":{"id":"command-1","type":"commandExecution","command":"swift test","commandActions":[],"cwd":"/workspace","status":"pending"}}"# + )) + } + try expectMalformed(method: "account/updated") { + try decoder.decode(notification( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"ultra"}"# + )) + } + try expectMalformed(method: "turn/plan/updated") { + try decoder.decode(notification( + method: "turn/plan/updated", + json: #"{"threadId":"thread-1","turnId":"turn-1","plan":[{"step":"Ship","status":"running"}]}"# + )) + } + try expectMalformed(method: "hook/started") { + try decoder.decode(notification( + method: "hook/started", + json: Self.hookFixture(status: "pending") + )) + } + try expectMalformed(method: "rawResponseItem/completed") { + try decoder.decode(notification( + method: "rawResponseItem/completed", + json: #"{"threadId":"thread-1","turnId":"turn-1","item":{"type":"local_shell_call","action":{},"status":"running"}}"# + )) + } + try expectMalformed(method: "item/fileChange/patchUpdated") { + try decoder.decode(notification( + method: "item/fileChange/patchUpdated", + json: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"file-1","changes":[{"path":"File.swift","kind":{"type":"update"}}]}"# + )) + } + try expectMalformed(method: "item/agentMessage/delta") { + try decoder.decode(notification( + method: "item/agentMessage/delta", + json: #"{"threadId":"thread-1","turnId":"turn-1","delta":"missing"}"# + )) + } + try expectMalformed(method: "item/agentMessage/delta") { + try decoder.decode(notification( + method: "item/agentMessage/delta", + json: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"","delta":"empty"}"# + )) + } + try expectMalformed(method: "item/agentMessage/delta") { + try decoder.decode(notification( + method: "item/agentMessage/delta", + json: #"{"threadId":"thread-1","turnId":"turn-1","itemId":" \n\t ","delta":"blank"}"# + )) + } + } + + @Test func extensibleWireValuesReachTheirDomainRepresentations() throws { + let decoder = AppServerNotificationDecoder() + + for status in ["running", "started"] { + let decoded = try decoder.decode(notification( + method: "turn/started", + json: #"{"threadId":"thread-1","turn":{"id":"turn-1","status":"\#(status)","items":[]}}"# + )) + #expect(decoded.payload == .turnStarted("turn-1")) + } + + let threadStatus = try decoder.decode(notification( + method: "thread/status/changed", + json: #"{"threadId":"thread-1","status":{"type":"paused"}}"# + )) + #expect(threadStatus.payload == .threadStatus(.unknown(rawValue: "paused"))) + + let futureItem = try decoder.decode(notification( + method: "item/started", + json: #"{"threadId":"thread-1","turnId":"turn-1","startedAtMs":1000,"item":{"id":"future-1","type":"futureItem","text":"payload"}}"# + )) + guard case .item(.started(let item)) = futureItem.payload else { + Issue.record("Expected a future item start mutation.") + return + } + #expect(item.kind == .unknown("futureItem")) + guard case .unknown(let rawItem) = item.content else { + Issue.record("Expected the future item payload to remain available.") + return + } + #expect(rawItem.rawType == "futureItem") + #expect(rawItem.text == "payload") + #expect(rawItem.payload != nil) + } + + @Test func currentItemAndTerminalPayloadsKeepTheirTypedContracts() throws { + let decoder = AppServerNotificationDecoder() + let started = try decoder.decode(notification( + method: "item/started", + json: #"{"threadId":"thread-1","turnId":"turn-1","startedAtMs":1000,"item":{"id":"command-1","type":"commandExecution","command":"swift test","commandActions":[],"cwd":"/workspace","status":"inProgress"}}"# + )) + guard case .item(.started(let item)) = started.payload else { + Issue.record("Expected a typed item start mutation.") + return + } + #expect(started.context == .init(threadID: "thread-1", turnID: "turn-1")) + #expect(item.id == "command-1") + + let error = try decoder.decode(notification( + method: "error", + json: #"{"threadId":"thread-1","turnId":"turn-1","error":{"message":"retrying","codexErrorInfo":"serverOverloaded","additionalDetails":"retry scheduled"},"willRetry":true}"# + )) + guard case .item(.turnDiagnostic(let diagnostic)) = error.payload else { + Issue.record("Expected a typed turn diagnostic mutation.") + return + } + #expect(error.context == .init(threadID: "thread-1", turnID: "turn-1")) + #expect(diagnostic == .init( + error: .init( + message: "retrying", + info: .serverOverloaded, + additionalDetails: "retry scheduled" + ), + willRetry: true + )) + + let futureTerminal = try decoder.decode(notification( + method: "turn/completed", + json: #"{"threadId":"thread-1","turn":{"id":"turn-1","status":"futureStatus","items":[]}}"# + )) + guard case .turnCompleted(let turn) = futureTerminal.payload else { + Issue.record("Expected a typed terminal turn.") + return + } + #expect(turn.status == "futureStatus") + } + + @Test func historicalAliasesAreUnknownConnectionDiagnosticsAndLegacyFileDeltaIsValidatedIgnore() throws { + let decoder = AppServerNotificationDecoder() + for method in ["turn/failed", "turn/cancelled", "item/updated", "agent/message"] { + let decoded = try decoder.decode(notification(method: method, json: #"{}"#)) + #expect(decoded.method == nil) + #expect(decoded.methodName == method) + #expect(decoded.disposition == .diagnostic) + #expect(decoded.payload == .connectionDiagnostic(.unknown(.init( + method: method, + params: Data(#"{}"#.utf8) + )))) + } + + let legacy = try decoder.decode(notification( + method: "item/fileChange/outputDelta", + json: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"file-1","delta":"patch"}"# + )) + #expect(legacy.method == .itemFileChangeOutputDelta) + #expect(legacy.disposition == .explicitIgnore) + #expect(legacy.payload == .ignored) + + try expectMalformed(method: "item/fileChange/outputDelta") { + try decoder.decode(notification( + method: "item/fileChange/outputDelta", + json: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"file-1"}"# + )) + } + } + + @Test func everyExplicitIgnoreHasACanonicalPayloadFixture() throws { + let fixtures: [AppServerNotificationDecoder.Method: String] = [ + .skillsChanged: #"{}"#, + .threadGoalUpdated: #"{"threadId":"thread-1","goal":{"createdAt":1,"objective":"Ship","status":"active","threadId":"thread-1","timeUsedSeconds":0,"tokensUsed":0,"updatedAt":1}}"#, + .threadGoalCleared: #"{"threadId":"thread-1"}"#, + .threadSettingsUpdated: #"{"threadId":"thread-1","threadSettings":{"approvalPolicy":"never","approvalsReviewer":"user","collaborationMode":{"mode":"default","settings":{"model":"gpt-5"}},"cwd":"/workspace","model":"gpt-5","modelProvider":"openai","sandboxPolicy":{"type":"dangerFullAccess"}}}"#, + .hookStarted: Self.hookFixture(status: "running"), + .hookCompleted: Self.hookFixture(status: "completed"), + .itemAutoApprovalReviewStarted: #"{"threadId":"thread-1","turnId":"turn-1","action":{"type":"command","command":"ls","cwd":"/workspace","source":"shell"},"review":{"status":"inProgress"},"reviewId":"review-1","startedAtMs":1}"#, + .itemAutoApprovalReviewCompleted: #"{"threadId":"thread-1","turnId":"turn-1","action":{"type":"command","command":"ls","cwd":"/workspace","source":"shell"},"review":{"status":"approved"},"reviewId":"review-1","startedAtMs":1,"completedAtMs":2,"decisionSource":"agent"}"#, + .rawResponseItemCompleted: #"{"threadId":"thread-1","turnId":"turn-1","item":{"type":"other"}}"#, + .commandExecOutputDelta: #"{"capReached":false,"deltaBase64":"","processId":"process-1","stream":"stdout"}"#, + .processOutputDelta: #"{"capReached":false,"deltaBase64":"","processHandle":"process-1","stream":"stderr"}"#, + .processExited: #"{"exitCode":0,"processHandle":"process-1","stderr":"","stderrCapReached":false,"stdout":"","stdoutCapReached":false}"#, + .itemCommandExecutionTerminalInteraction: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","processId":"process-1","stdin":"input"}"#, + .itemFileChangeOutputDelta: #"{"threadId":"thread-1","turnId":"turn-1","itemId":"file-1","delta":"patch"}"#, + .mcpServerOAuthLoginCompleted: #"{"name":"server","success":true}"#, + .mcpServerStartupStatusUpdated: #"{"name":"server","status":"ready"}"#, + .appListUpdated: #"{"data":[]}"#, + .remoteControlStatusChanged: #"{"installationId":"installation-1","serverName":"server","status":"connected"}"#, + .externalAgentConfigImportProgress: #"{"importId":"import-1","itemTypeResults":[]}"#, + .externalAgentConfigImportCompleted: #"{"importId":"import-1","itemTypeResults":[]}"#, + .fsChanged: #"{"changedPaths":["/workspace/File.swift"],"watchId":"watch-1"}"#, + .threadCompacted: #"{"threadId":"thread-1","turnId":"turn-1"}"#, + .fuzzyFileSearchSessionUpdated: #"{"files":[],"query":"File","sessionId":"search-1"}"#, + .fuzzyFileSearchSessionCompleted: #"{"sessionId":"search-1"}"#, + .threadRealtimeStarted: #"{"threadId":"thread-1","version":"v1"}"#, + .threadRealtimeItemAdded: #"{"threadId":"thread-1","item":{}}"#, + .threadRealtimeTranscriptDelta: #"{"threadId":"thread-1","delta":"hello","role":"assistant"}"#, + .threadRealtimeTranscriptDone: #"{"threadId":"thread-1","role":"assistant","text":"hello"}"#, + .threadRealtimeOutputAudioDelta: #"{"threadId":"thread-1","audio":"AA=="}"#, + .threadRealtimeSDP: #"{"threadId":"thread-1","sdp":"offer"}"#, + .threadRealtimeError: #"{"threadId":"thread-1","message":"failed"}"#, + .threadRealtimeClosed: #"{"threadId":"thread-1"}"#, + ] + let ignoredMethods = Set(AppServerNotificationDecoder.Method.allCases.filter { + $0.disposition == .explicitIgnore + }) + #expect(Set(fixtures.keys) == ignoredMethods) + + let decoder = AppServerNotificationDecoder() + for method in ignoredMethods { + let json = try #require(fixtures[method]) + let decoded = try decoder.decode(notification(method: method.rawValue, json: json)) + #expect(decoded.method == method) + #expect(decoded.disposition == .explicitIgnore) + #expect(decoded.payload == .ignored) + } + } + + @Test func loginCompletionRemainsTypedForAccountEventHubMigration() throws { + let decoded = try AppServerNotificationDecoder().decode(notification( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + )) + guard case .account(.loginCompleted(let completion)) = decoded.payload else { + Issue.record("Expected a typed login completion.") + return + } + #expect(completion.loginID?.rawValue == "login-1") + #expect(completion.success) + } + + private static func hookFixture(status: String) -> String { + #"{"threadId":"thread-1","turnId":"turn-1","run":{"displayOrder":0,"entries":[],"eventName":"preToolUse","executionMode":"sync","handlerType":"command","id":"hook-1","scope":"turn","sourcePath":"/workspace/hook","startedAt":1,"status":"\#(status)"}}"# + } +} + +@Suite("AccountEventHub") +struct AccountEventHubTests { + @Test func sparseRateLimitUpdateMergesIntoLastFullReadSnapshot() async throws { + let hub = AccountEventHub() + await hub.replaceRateLimits(with: .init(rateLimits: .init( + limitID: "codex", + primary: .init(usedPercent: 10, windowDurationMins: 15), + secondary: .init(usedPercent: 20, windowDurationMins: 10_080), + planType: "plus" + ))) + let events = await hub.events() + #expect(await hub.subscriberCountForTesting == 1) + + await hub.apply(.rateLimitsUpdated(.init( + limitID: "codex", + primary: .init(usedPercent: 30, windowDurationMins: 15) + ))) + await hub.apply(.rateLimitsUpdated(.init( + limitID: "codex", + primary: .init(usedPercent: 40, windowDurationMins: 15) + ))) + await hub.apply(.updated(.init(authMode: .chatGPT, planType: .plus))) + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .rateLimitsUpdated(.init( + planType: "plus", + windows: [ + .init(windowDurationMinutes: 15, usedPercent: 40), + .init(windowDurationMinutes: 10_080, usedPercent: 20), + ] + ))) + #expect(try await iterator.next() == .accountUpdated) + + await events.cancel() + #expect(await hub.subscriberCountForTesting == 0) + await events.cancel() + #expect(await hub.subscriberCountForTesting == 0) + } + + @Test func taskCancellationSynchronouslyReleasesWaitingSubscription() async throws { + let hub = AccountEventHub() + let events = await hub.events() + let waiter = Task { () throws -> CodexAccountEvent? in + var iterator = events.makeAsyncIterator() + return try await iterator.next() + } + await events.waitUntilNextSuspendsForTesting() + waiter.cancel() + #expect(try await waiter.value == nil) + #expect(await hub.subscriberCountForTesting == 0) + } + + @Test func nullableSparseFieldsDoNotClearPreviouslyObservedValues() { + let base = AppServerAPI.Account.RateLimits.Snapshot( + limitID: "codex", + primary: .init( + usedPercent: 10, + windowDurationMins: 15, + resetsAt: 1_700_000_000 + ), + secondary: .init(usedPercent: 20, windowDurationMins: 10_080), + planType: "plus" + ) + let merged = base.merging(.init( + limitID: "codex", + primary: .init(usedPercent: 40, windowDurationMins: 15) + )) + #expect(merged.limitID == "codex") + #expect(merged.primary?.usedPercent == 40) + #expect(merged.primary?.windowDurationMins == 15) + #expect(merged.primary?.resetsAt == 1_700_000_000) + #expect(merged.secondary == base.secondary) + #expect(merged.planType == "plus") + } + + @Test func loginReplaysCompletionAndAccountUpdateReceivedBeforeBinding() async throws { + let registry = LoginRegistry() + let state = try await registry.reserve( + readinessTimeout: nil, + cancel: { _, _ in .cancelled }, + closeConnection: {} + ) + await registry.apply(.init(loginID: "login-new", success: true)) + await registry.applyAccountUpdate(.init(authMode: .chatGPT, planType: .plus)) + let handle = try await registry.bind( + state, + id: "login-new", + authenticationURL: try #require(URL(string: "https://example.com/login")) + ) + #expect(try await handle.result() == .succeeded) + } +} + +private func notification(method: String, json: String) -> JSONRPC.Notification { + .init(method: method, params: Data(json.utf8)) +} + +private func expectMalformed( + method: String, + operation: () throws -> AppServerNotificationDecoder.DecodedNotification +) throws { + do { + _ = try operation() + Issue.record("Expected malformed notification for \(method).") + } catch let CodexAppServerError.malformedNotification(failure) { + #expect(failure.method == method) + #expect(failure.rawData != nil) + } +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift new file mode 100644 index 00000000..e3481b12 --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -0,0 +1,8877 @@ +import Foundation +import Synchronization +import Testing + +import CodexAppServerKitTesting +@testable import CodexAppServerKit + +@Suite("CodexAppServerKit") +struct CodexAppServerKitTests { + @Test func localProcessConfigurationOwnsDefaultCodexHome() { + let fromHome = CodexAppServer.Configuration.LocalProcess(environment: [ + "HOME": "/tmp/user-home", + ]) + #expect(fromHome.codexHomeURL.path == "/tmp/user-home/.codex") + + let fromCodexHome = CodexAppServer.Configuration.LocalProcess(environment: [ + "CODEX_HOME": "/tmp/codex-home", + "HOME": "/tmp/user-home", + ]) + #expect(fromCodexHome.codexHomeURL.path == "/tmp/codex-home") + + let appSupport = URL(fileURLWithPath: "/tmp/app-support", isDirectory: true) + let containerDefault = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( + environment: [:], + homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + applicationSupportDirectory: appSupport + ) + #expect(containerDefault.path == "/tmp/app-support/Codex") + + let homeFallback = CodexAppServer.Configuration.LocalProcess.defaultCodexHomeURL( + environment: [:], + homeDirectoryForCurrentUser: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + applicationSupportDirectory: nil + ) + #expect(homeFallback.path == "/tmp/home/Library/Application Support/Codex") + } + + @Test func reasoningTextCoalescesDuplicateFragmentsAndKeepsMarkdownBlocks() { + let review = """ + **Reviewing inspection needs** + + I need to inspect the changes. + """ + let slowness = """ + **Investigating potential slowness** + + I need to inspect the running command. + """ + + let reasoning = CodexReasoning( + summary: [ + review, + review, + slowness, + slowness, + ], + content: ["raw", "raw"] + ) + + #expect(reasoning.summary == [review, slowness]) + #expect(reasoning.content == ["raw"]) + #expect(reasoning.text == "\(review)\n\n\(slowness)") + } + + @Test func localProcessConfigurationResolvesExplicitExecutableCommandNames() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let binURL = rootURL.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootURL) + } + + let executableURL = binURL.appendingPathComponent("codex") + try """ + #!/bin/sh + exit 0 + """ + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + + let configuration = AppServerProcessTransport.Configuration( + executable: "codex", + environment: ["PATH": binURL.path], + codexHomeURL: rootURL.appendingPathComponent("codex-home", isDirectory: true) + ) + + #expect(configuration.executable == executableURL.path) + #expect(configuration.arguments == [ + "-c", + CodexAppServerExecutable.fileBackedAuthConfiguration, + "app-server", + "--listen", + "stdio://", + ]) + } + + @Test func localProcessConfigurationResolvesStandaloneInstallerExecutableOutsidePATH() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let homeURL = rootURL.appendingPathComponent("home", isDirectory: true) + let binURL = homeURL.appendingPathComponent(".local/bin", isDirectory: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootURL) + } + + let executableURL = binURL.appendingPathComponent("codex") + try """ + #!/bin/sh + exit 0 + """ + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + + let configuration = AppServerProcessTransport.Configuration( + environment: [ + "HOME": homeURL.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ], + codexHomeURL: rootURL.appendingPathComponent("codex-home", isDirectory: true) + ) + + #expect(configuration.executable == executableURL.path) + } + + @Test func processTransportAnswersServerInitiatedRequestsThroughConfiguredHandler() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let responseURL = rootURL.appendingPathComponent("response.json") + let executableURL = rootURL.appendingPathComponent("fake-app-server") + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootURL) + } + + try """ + #!/bin/sh + printf '%s\\n' '{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","startedAtMs":123}}' + IFS= read -r line + printf '%s\\n' "$line" > "$RESPONSE_PATH" + """ + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + + let recorder = ServerRequestRecorder() + let transport = try AppServerProcessTransport( + configuration: .init( + executable: executableURL.path, + arguments: [], + environment: ["RESPONSE_PATH": responseURL.path], + codexHomeURL: rootURL.appendingPathComponent("codex-home", isDirectory: true) + ), + connectionEventHub: ConnectionEventHub() + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + processTerminationToken: transport.processTerminationToken, + handler: { request in + await recorder.append(request) + return .approval(.accept) + } + ) + + let wroteResponse = await eventually(attempts: 100) { + FileManager.default.fileExists(atPath: responseURL.path) + } + #expect(wroteResponse) + + let request = try #require(await recorder.requests().first) + #expect(request.method == "item/commandExecution/requestApproval") + guard case .commandExecutionApproval(let approval) = request else { + Issue.record("Expected a command execution approval request.") + return + } + #expect(approval.threadID == "thread-1") + #expect(approval.turnID == "turn-1") + #expect(approval.itemID == "item-1") + #expect(approval.startedAtMs == 123) + + let responseData = try Data(contentsOf: responseURL) + let response = try #require( + JSONSerialization.jsonObject(with: responseData) as? [String: Any] + ) + #expect(response["id"] as? String == "approval-1") + let result = try #require(response["result"] as? [String: Any]) + #expect(result["decision"] as? String == "accept") + await harness.close() + } + + @Test func processSpawnClosePlanPreservesStandardIOFileDescriptors() { + let closeDescriptors = AppServerProcessFileDescriptorPlan + .childPipeDescriptorsToClose([0, 1, 2, 3, 4, 5]) + + #expect(closeDescriptors == [3, 4, 5]) + } + + @Test func testRuntimeStartsAppServerWithoutLaunchingProcess() async throws { + let runtime = try await CodexAppServerTestRuntime.start(configuration: .init( + localProcess: .init( + codexHomeURL: URL(fileURLWithPath: "/tmp/codex", isDirectory: true) + ) + )) + try await runtime.transport.enqueueThreadStart(threadID: "thread-test", model: "gpt-5") + + let thread = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + options: .init(model: "gpt-5") + ) + + #expect(thread.id == "thread-test") + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "thread/start", + ]) + #expect(await runtime.transport.recordedNotifications().map(\.method) == [ + "initialized" + ]) + await runtime.close() + } + + @Test func publicConfigurationRoutesTypedServerRequestsToTheHost() async throws { + let configuration = CodexAppServer.Configuration { request in + guard case .commandExecutionApproval(let approval) = request else { + return try await CodexAppServer.Configuration.defaultServerRequestHandler( + request: request + ) + } + #expect(approval.command == "git status") + return .approval(.accept) + } + let runtime = try await CodexAppServerTestRuntime.start(configuration: configuration) + let requestID = CodexServerRequestID.string("approval-public-handler") + + try await runtime.transport.emitServerRequest( + id: requestID, + method: "item/commandExecution/requestApproval", + params: Data( + #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","startedAtMs":123,"command":"git status"}"#.utf8 + ) + ) + + let response = await runtime.transport.serverRequestResponse(for: requestID) + #expect(response == .result(Data(#"{"decision":"accept"}"#.utf8))) + await runtime.close() + } + + @Test func testRuntimeUsesTypedConfigurationFixtures() async throws { + let configURL = URL(fileURLWithPath: "/tmp/codex/config.toml") + let layerMetadata = try CodexAppServerTestConfigurationLayerMetadata( + source: .user(file: configURL, profile: nil), + version: "config-v1" + ) + let result = try CodexAppServerTestConfigurationReadResult( + configuration: .init( + model: "gpt-5-codex", + reviewModel: "gpt-5-codex-review", + reasoningEffort: .high, + serviceTier: "flex" + ), + origins: ["model": layerMetadata], + layers: [try .init( + metadata: layerMetadata, + configuration: .object(["model": .string("gpt-5-codex")]) + )] + ) + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueConfiguration(result) + + let configuration = try await runtime.server.configuration() + + #expect(configuration == result.configuration) + #expect( + await runtime.transport.recordedRequests(for: .configurationRead).map(\.request) + == [.configurationRead] + ) + await runtime.close() + } + + @Test func typedConfigurationWriteFixturePreservesOverrideContract() async throws { + let configURL = URL(fileURLWithPath: "/tmp/codex/config.toml") + let layerMetadata = try CodexAppServerTestConfigurationLayerMetadata( + source: .system(file: URL(fileURLWithPath: "/etc/codex/managed.toml")), + version: "managed-v1" + ) + let writeResult = try CodexAppServerTestConfigurationWriteResult( + status: .okOverridden, + version: "config-v2", + fileURL: configURL, + overriddenMetadata: try .init( + message: "Managed configuration overrides this value.", + overridingLayer: layerMetadata, + effectiveValue: .string("gpt-5-codex") + ) + ) + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueConfigurationWrite(writeResult) + var patch = CodexConfigurationPatch() + patch.setReviewModel("gpt-5-codex-review") + + try await runtime.server.updateConfiguration(patch) + + let request = try #require( + await runtime.transport.recordedRequests(for: .configurationUpdate).last + ) + #expect(request.request == .configurationUpdate(patch)) + await runtime.close() + } + + @Test func configurationFixturesRejectInvalidOwnershipMetadata() throws { + #expect(throws: CodexAppServerTestError.invalidFixture( + "configuration layer file must be an absolute file URL" + )) { + _ = try CodexAppServerTestConfigurationLayerMetadata( + source: .system(file: URL(string: "https://example.com/config.toml")!), + version: "config-v1" + ) + } + + let configURL = URL(fileURLWithPath: "/tmp/codex/config.toml") + #expect(throws: CodexAppServerTestError.invalidFixture( + "an overridden configuration write requires override metadata" + )) { + _ = try CodexAppServerTestConfigurationWriteResult( + status: .okOverridden, + version: "config-v1", + fileURL: configURL + ) + } + } + + @Test func manualDeadlineClockResumesOnlyAfterExplicitAdvance() async throws { + let clock = CodexAppServerTestDeadlineClock() + let sleeper = Task { + try await clock.codexDeadlineClock.sleep(.seconds(5)) + } + + try await clock.waitForSleeperCount(1) + clock.advance(by: .seconds(5)) + try await sleeper.value + clock.close() + } + + @Test func testTransportHoldsRequestsAtExplicitGate() async throws { + let transport = CodexAppServerTestTransport() + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "ping", gate: gate) + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + } + + await transport.waitForRequest(method: "ping") + #expect(await transport.maxActiveCount(for: "ping") == 1) + + await gate.open() + try await task.value + await harness.close() + } + + @Test func testGateCancellationThrowsAndRemovesItsWaiter() async throws { + let gate = CodexAppServerTestGate() + let task = Task { + try await gate.wait() + } + await gate.waitUntilBlocked() + + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + await gate.open() + + let closedGate = CodexAppServerTestGate() + await closedGate.close() + await #expect(throws: CancellationError.self) { + try await closedGate.wait() + } + } + + @Test func unstubbedTestTransportRequestFailsWithRequestIdentity() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + let _: EmptyResponse = try await harness.client.send( + method: "unstubbed/probe", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected an unstubbed test transport request to fail.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error else { + Issue.record("Expected a typed request failure, got \(error).") + return + } + #expect(failure.requestID == 1) + #expect(failure.method == "unstubbed/probe") + #expect(failure.purpose == .operation("unstubbed/probe")) + #expect(failure.kind == .transport(.contractViolation( + message: "No test response is configured for unstubbed/probe." + ))) + } + + await harness.close() + } + + @Test func testTransportReservesQueuedResponseBeforeGateWait() async throws { + struct PingResponse: Codable, Equatable, Sendable { + var value: String + } + + let transport = CodexAppServerTestTransport() + try await transport.enqueue(PingResponse(value: "first"), for: "ping") + try await transport.enqueue(PingResponse(value: "second"), for: "ping") + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "ping", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + let first = Task { + try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: PingResponse.self + ) + } + await transport.waitForRequest(method: "ping") + + let second = Task { + try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: PingResponse.self + ) + } + + #expect(try await second.value == PingResponse(value: "second")) + await gate.open() + #expect(try await first.value == PingResponse(value: "first")) + await harness.close() + } + + @Test func initializeSendsHandshakeAndInitializedNotification() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + let response = try await client.initialize(clientName: "TestClient", clientVersion: "1") + + #expect(response.codexHome == "/tmp/codex") + #expect(await transport.recordedRequests().map(\.method) == ["initialize"]) + #expect(await transport.recordedNotifications().map(\.method) == ["initialized"]) + let params = try #require(await transport.recordedRequests().first?.params) + let decoded = try JSONDecoder().decode(AppServerAPI.Initialize.Params.self, from: params) + #expect(decoded.clientInfo.name == "TestClient") + #expect(decoded.clientInfo.version == "1") + await harness.close() + } + + @Test func concurrentInitializeCallsShareOneHandshake() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Initialize.Response(codexHome: "/tmp/codex"), for: "initialize") + let gate = CodexAppServerTestGate() + await transport.hold(method: "initialize", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + let first = Task { + try await client.initialize(clientName: "TestClient", clientVersion: "1") + } + await transport.waitForRequest(method: "initialize") + let gateOpener = Task { + do { + try await withTimeout { + while await client.initializationWaiterCountForTesting() == 0 { + await Task.yield() + } + } + } catch { + await gate.open() + throw error + } + await gate.open() + } + + let secondResponse = try await client.initialize( + clientName: "TestClient", + clientVersion: "1" + ) + let firstResponse = try await first.value + try await gateOpener.value + + #expect(firstResponse == secondResponse) + #expect(await transport.recordedRequests(method: "initialize").count == 1) + #expect(await transport.recordedNotifications().map(\.method) == ["initialized"]) + await harness.close() + } + + @Test func appServerClosesTransportWhenInitializationFails() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32000, message: "initialize failed", for: "initialize") + + do { + _ = try await CodexAppServer.testing(transport: transport) + Issue.record("Expected initialization failure.") + } catch { + #expect(await transport.isClosedForTesting()) + } + } + + @Test func appServerStartThreadSerializesDomainOptions() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), + for: "thread/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let thread = try await server.startThread( + in: workspace, + instructions: .init(base: "Base", developer: "Developer"), + options: .init( + model: "gpt-5", + sandbox: .workspaceWrite, + permissions: .profile(id: "codex-default"), + ephemeral: true, + config: ["experimental": .bool(true)], + personality: .pragmatic, + serviceName: "app-server-kit-test", + sessionStartSource: .startup, + threadSource: "automation" + ) + ) + + #expect(thread.id == "thread-1") + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "thread/start") + let params = try JSONDecoder().decode( + AppServerAPI.Thread.Start.Params.self, from: request.params) + #expect(params.cwd == workspace.path) + #expect(params.model == "gpt-5") + #expect(params.ephemeral == true) + #expect(params.baseInstructions == "Base") + #expect(params.developerInstructions == "Developer") + #expect(params.approvalPolicy == "on-request") + #expect(params.approvalsReviewer == "auto_review") + #expect(params.sandbox == "workspace-write") + #expect(params.permissions == .profileID("codex-default")) + #expect(params.config == ["experimental": .bool(true)]) + #expect(params.personality == "pragmatic") + #expect(params.serviceName == "app-server-kit-test") + #expect(params.sessionStartSource == .startup) + #expect(params.threadSource?.rawValue == "automation") + } + + @Test func threadOptionWireValuesUseAppServerConfigSchema() { + #expect(CodexApprovalMode.autoReview.approvalPolicy == "on-request") + #expect(CodexApprovalMode.denyAll.approvalPolicy == "never") + #expect(CodexSandbox.readOnly.threadSandboxValue == "read-only") + #expect(CodexSandbox.workspaceWrite.threadSandboxValue == "workspace-write") + #expect(CodexSandbox.fullAccess.threadSandboxValue == "danger-full-access") + } + + @Test func appServerResumeThreadPreservesServerReturnedModel() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume( + .init( + id: "thread-1", + workspace: URL(fileURLWithPath: "/tmp/project", isDirectory: true) + ), + model: "gpt-5" + ) + + let thread = try await runtime.server.resumeThread("thread-1") + + #expect(thread.id == "thread-1") + #expect(thread.model == "gpt-5") + let request = try #require(await runtime.transport.recordedRequests().last) + let params = try request.decodeParams(AppServerAPI.Thread.Resume.Params.self) + #expect(params.threadID == "thread-1") + #expect(params.model == nil) + } + + @Test func resumedThreadSeedsNestedTerminalReceivedBeforeResumeResponse() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-resume-terminal", + turns: [.init(id: "turn-resume-terminal", state: .inProgress)] + )) + await runtime.transport.holdNext(method: "thread/resume", gate: gate) + + let resumeTask = Task { + try await runtime.server.resumeThread("thread-resume-terminal") + } + await runtime.transport.waitForRequest(method: "thread/resume") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-resume-terminal", + turn: .init( + id: "turn-resume-terminal", + status: "completed" + ) + ) + ) + await gate.open() + let thread = try await resumeTask.value + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-resume-terminal") + ) + + let events = try await collect(thread.events) + #expect(events.contains(.terminal(.completed(.init( + turnID: "turn-resume-terminal", + transcript: .init(), + transcriptItemsLoadState: .full + ))))) + } + + @Test func resumedThreadTransfersNestedProtocolViolationAfterAssociation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-resume-malformed", + turns: [.init(id: "turn-resume-malformed", state: .inProgress)] + )) + await runtime.transport.holdNext(method: "thread/resume", gate: gate) + + let resumeTask = Task { + try await runtime.server.resumeThread("thread-resume-malformed") + } + await runtime.transport.waitForRequest(method: "thread/resume") + try await runtime.transport.emitServerNotificationJSON( + method: "turn/completed", + json: #"{"turn":{"id":"turn-resume-malformed"}}"# + ) + await gate.open() + let thread = try await resumeTask.value + + do { + _ = try await collect(thread.events) + Issue.record("Expected associated malformed terminal failure.") + } catch let error as CodexAppServerError { + guard case .connectionTerminated(.transportFailure( + .protocolViolation(_, let rawData) + )) = error else { + Issue.record("Expected connection protocol violation, got \(error).") + return + } + #expect(rawData == Data(#"{"turn":{"id":"turn-resume-malformed"}}"#.utf8)) + } + } + + @Test func threadReadSeedsNestedTerminalReceivedBeforeReadResponse() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-read-terminal")) + let thread = try await runtime.server.resumeThread("thread-read-terminal") + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-read-terminal", + turns: [.init(id: "turn-read-terminal", state: .inProgress)] + )) + await runtime.transport.holdNext(method: "thread/read", gate: gate) + + let readTask = Task { try await thread.read(includeTurns: true) } + await runtime.transport.waitForRequest(method: "thread/read") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-read-terminal", + turn: .init(id: "turn-read-terminal", status: "completed") + ) + ) + await gate.open() + _ = try await readTask.value + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-read-terminal") + ) + + let events = try await collect(thread.events) + #expect(events.contains(.terminal(.completed(.init( + turnID: "turn-read-terminal", + transcript: .init(), + transcriptItemsLoadState: .full + ))))) + } + + @Test func turnListSeedsNestedTerminalReceivedBeforeListResponse() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-list-turns-terminal")) + let thread = try await runtime.server.resumeThread("thread-list-turns-terminal") + let gate = CodexAppServerTestGate() + let listedTurn = try CodexAppServerTestTurn( + snapshot: .init(id: "turn-list-turns-terminal", state: .inProgress), + items: [] + ) + try await runtime.transport.enqueueThreadTurns(.init(turns: [listedTurn])) + await runtime.transport.holdNext(method: "thread/turns/list", gate: gate) + + let listTask = Task { try await thread.listTurns() } + await runtime.transport.waitForRequest(method: "thread/turns/list") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-list-turns-terminal", + turn: .init( + id: "turn-list-turns-terminal", + status: "completed" + ) + ) + ) + await gate.open() + _ = try await listTask.value + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-list-turns-terminal") + ) + + let events = try await collect(thread.events) + #expect(events.contains(.terminal(.completed(.init( + turnID: "turn-list-turns-terminal", + transcript: .init(), + transcriptItemsLoadState: .full + ))))) + } + + @Test func appServerStartReviewStartsThreadThenReview() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-source" + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let review = try await runtime.server.startReview( + in: workspace, + target: .baseBranch("main"), + instructions: .init(base: "Base", developer: "Developer"), + options: .init(model: "gpt-5"), + delivery: .inline + ) + + #expect(review.threadID == "thread-source") + #expect(review.turnID == "turn-review") + #expect(review.reviewThreadID == "thread-source") + #expect(review.identity == CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + model: "gpt-5" + )) + + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "review/start", + ]) + let threadStart = try requests[1].decodeParams(AppServerAPI.Thread.Start.Params.self) + #expect(threadStart.cwd == workspace.path) + #expect(threadStart.model == "gpt-5") + #expect(threadStart.baseInstructions == "Base") + #expect(threadStart.developerInstructions == "Developer") + + let reviewStart = try requests[2].decodeParams(AppServerAPI.Review.Start.Params.self) + #expect(reviewStart.threadID == "thread-source") + #expect(reviewStart.target == .baseBranch("main")) + #expect(reviewStart.delivery == .inline) + } + + @Test func threadStartReviewTreatsReturnedSourceThreadIDAsInline() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-1" + ), + for: "review/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", model: "gpt-5", client: client, router: router, connectionLease: harness.lease) + + let review = try await thread.startReview(target: .baseBranch("main")) + + #expect(review.threadID == "thread-1") + #expect(review.reviewThreadID == "thread-1") + #expect(review.model == "gpt-5") + #expect(review.identity.reviewThreadID == nil) + #expect(review.identity.model == "gpt-5") + } + + @Test func reviewStartRequiresTheResponseEventThreadIdentity() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + #"{"turn":{"id":"turn-review","status":"inProgress"}}"#, + for: "review/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = CodexThread( + id: "thread-1", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + + do { + _ = try await thread.startReview(target: .baseBranch("main")) + Issue.record("Expected a missing reviewThreadId to fail decoding.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .invalidResponse = failure.kind else { + Issue.record("Expected a typed invalid response, got \(error).") + await harness.close() + return + } + #expect(failure.method == "review/start") + } + await harness.close() + } + + @Test func appServerStartReviewDeletesSourceThreadWhenReviewStartFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + await runtime.transport.enqueueFailure( + code: -32602, + message: "invalid review target", + for: "review/start" + ) + try await runtime.transport.enqueueEmpty(for: "thread/delete") + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + do { + _ = try await runtime.server.startReview( + in: workspace, + target: .baseBranch("missing") + ) + Issue.record("Expected review start failure.") + } catch { + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "review/start", + "thread/delete", + ]) + let delete = try requests[3].decodeParams(AppServerAPI.Thread.Delete.Params.self) + #expect(delete.threadID == "thread-source") + } + } + + @Test func appServerStartReviewDeletesSourceThreadWhenCancelledDuringThreadStart() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let threadStartGate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/start", + gate: threadStartGate + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let task = Task { + try await runtime.server.startReview( + in: workspace, + target: .baseBranch("main") + ) + } + await runtime.transport.waitForRequest(method: "thread/start") + task.cancel() + await threadStartGate.open() + + do { + _ = try await withTimeout { + try await task.value + } + Issue.record("Expected cancelled thread start failure.") + } catch is CancellationError { + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "thread/delete", + ]) + let delete = try requests[2].decodeParams(AppServerAPI.Thread.Delete.Params.self) + #expect(delete.threadID == "thread-source") + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + } + + @Test func appServerStartReviewDeletesSourceThreadWhenCancelledAfterThreadStart() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let reviewStartGate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + await runtime.transport.enqueueFailure( + code: -32602, + message: "cancelled review start", + for: "review/start" + ) + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.holdNextIgnoringCancellation( + method: "review/start", + gate: reviewStartGate + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let task = Task { + try await runtime.server.startReview( + in: workspace, + target: .baseBranch("main") + ) + } + await runtime.transport.waitForRequest(method: "review/start") + task.cancel() + await reviewStartGate.open() + + do { + _ = try await withTimeout { + try await task.value + } + Issue.record("Expected cancelled review start failure.") + } catch { + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "review/start", + "thread/delete", + ]) + let delete = try requests[3].decodeParams(AppServerAPI.Thread.Delete.Params.self) + #expect(delete.threadID == "thread-source") + } + } + + @Test func appServerStartReviewCleansDetachedReviewWhenCancelledAfterReviewStart() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let reviewStartGate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.holdNextIgnoringCancellation( + method: "review/start", + gate: reviewStartGate + ) + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let task = Task { + try await runtime.server.startReview( + in: workspace, + target: .baseBranch("main"), + delivery: .detached + ) + } + await runtime.transport.waitForRequest(method: "review/start") + task.cancel() + await reviewStartGate.open() + await runtime.transport.waitForRequest(method: "turn/interrupt") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review", + turn: .init(id: "turn-review", status: "interrupted") + ) + ) + + do { + _ = try await withTimeout { + try await task.value + } + Issue.record("Expected cancelled detached review start failure.") + } catch is CancellationError { + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/start", + "review/start", + "turn/interrupt", + "thread/delete", + "thread/delete", + ]) + let deletedThreadIDs = try requests.suffix(2).map { + try $0.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + } + #expect(deletedThreadIDs == ["thread-review", "thread-source"]) + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + await runtime.close() + } + + @Test func inlineReviewStartCancellationKeepsOuterThreadEventOwnerWhenInterruptRedirects() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-source" + ) + await transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-review but found turn-review-child", + for: "turn/interrupt" + ) + try await transport.enqueueEmpty(for: "turn/interrupt") + let reviewStartGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + method: "review/start", + gate: reviewStartGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = CodexThread( + id: "thread-source", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + + let reviewStart = Task { + try await thread.startReview(target: .baseBranch("main")) + } + await transport.waitForRequest(method: "review/start") + reviewStart.cancel() + await reviewStartGate.open() + await transport.waitForRequest(method: "turn/interrupt", count: 2) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review-child", + turn: .init(id: "turn-review-child", status: "interrupted") + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-source", + turnID: "turn-review", + item: .init( + id: "review-output", + type: "agentMessage", + text: "Review interrupted" + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-source", + turn: .init(id: "turn-review", status: "interrupted") + ) + ) + + await #expect(throws: CancellationError.self) { + try await reviewStart.value + } + await harness.close() + } + + @Test func standaloneStartThreadDeletesLateIdentityBeforeCancellationReturns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let startGate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadStart(threadID: "thread-late", model: "gpt-5") + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.holdNextIgnoringCancellation(method: "thread/start", gate: startGate) + + let task = Task { + try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true) + ) + } + await runtime.transport.waitForRequest(method: "thread/start") + task.cancel() + await startGate.open() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "thread/start", + "thread/delete", + ]) + let delete = try #require(await runtime.transport.recordedRequests().last) + #expect( + try delete.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + == "thread-late" + ) + } + + @Test func forkThreadDeletesLateForkBeforeCancellationReturns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let forkGate = CodexAppServerTestGate() + try await runtime.transport.enqueue( + AppServerAPI.Thread.Fork.Response(thread: .init(id: "thread-fork")), + for: "thread/fork" + ) + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.holdNextIgnoringCancellation(method: "thread/fork", gate: forkGate) + + let task = Task { + try await runtime.server.forkThread("thread-source") + } + await runtime.transport.waitForRequest(method: "thread/fork") + task.cancel() + await forkGate.open() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "thread/fork", + "thread/delete", + ]) + let delete = try #require(await runtime.transport.recordedRequests().last) + #expect( + try delete.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + == "thread-fork" + ) + } + + @Test func loginChatGPTCancelsLateLoginBeforeCancellationReturns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let loginGate = CodexAppServerTestGate() + try await runtime.transport.enqueueChatGPTLogin( + loginID: "login-late", + authenticationURL: URL(string: "https://example.test/auth")! + ) + try await runtime.transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) + await runtime.transport.holdNextIgnoringCancellation( + method: "account/login/start", + gate: loginGate + ) + + let task = Task { + try await runtime.server.loginChatGPT() + } + await runtime.transport.waitForRequest(method: "account/login/start") + task.cancel() + await loginGate.open() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(await runtime.transport.recordedRequests().map(\.method) == [ + "initialize", + "account/login/start", + "account/login/cancel", + ]) + let cancel = try #require(await runtime.transport.recordedRequests().last) + #expect( + try cancel.decodeParams(AppServerAPI.Account.Login.Cancel.Params.self).loginID + == "login-late" + ) + } + + @Test func appServerListThreadsSerializesQueryOptions() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: [], nextCursor: "next"), + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + + let page = try await server.listThreads(.init( + archived: false, + cursor: "cursor", + workspace: workspace, + limit: 10, + searchTerm: "review", + modelProviders: ["openai"], + sortDirection: .descending, + sortKey: .recencyAt, + sourceKinds: [.appServer, .subAgentReview], + useStateDBOnly: true + )) + + #expect(page.nextCursor == "next") + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "thread/list") + let params = try JSONDecoder().decode( + AppServerAPI.Thread.List.Params.self, + from: request.params + ) + #expect(params.archived == false) + #expect(params.cursor == "cursor") + #expect(params.cwd == .paths([workspace.path])) + #expect(params.limit == 10) + #expect(params.searchTerm == "review") + #expect(params.modelProviders == ["openai"]) + #expect(params.sortDirection == "desc") + #expect(params.sortKey == "recency_at") + #expect(params.sourceKinds == ["appServer", "subAgentReview"]) + #expect(params.useStateDbOnly == true) + } + + @Test func appServerListThreadsSerializesMultipleWorkspaceFilters() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: []), + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + let app = URL(fileURLWithPath: "/tmp/project/App", isDirectory: true) + let tools = URL(fileURLWithPath: "/tmp/project/Tools", isDirectory: true) + + _ = try await server.listThreads(.init(workspaces: [app, tools])) + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Thread.List.Params.self, + from: request.params + ) + #expect(params.cwd == .paths([app.path, tools.path])) + } + + @Test func appServerListThreadsTreatsClearedWorkspaceFiltersAsNoFilter() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: []), + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + var query = CodexThreadQuery( + workspaces: [URL(fileURLWithPath: "/tmp/project", isDirectory: true)] + ) + query.workspaces = [] + + _ = try await server.listThreads(query) + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Thread.List.Params.self, + from: request.params + ) + #expect(params.cwd == nil) + } + + @Test func appServerListThreadsMapsStatusAndRecency() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "data": [ + { + "id": "thread-active", + "recencyAt": 1234, + "status": { + "type": "active", + "activeFlags": ["waitingOnApproval"] + } + } + ] + } + """, + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let page = try await server.listThreads() + let snapshot = try #require(page.threads.first) + + #expect(snapshot.hasField(.recencyAt)) + #expect(snapshot.recencyAt == Date(timeIntervalSince1970: 1234)) + #expect(snapshot.hasField(.status)) + #expect(snapshot.status == .active(activeFlags: [.waitingOnApproval])) + } + + @Test func threadListTreatsEmptyTurnsAsUnloaded() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: [ + .init(id: "thread-empty", turns: []) + ]), + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let page = try await server.listThreads() + + #expect(page.threads.first?.turns == nil) + } + + @Test func threadListTurnItemsAreNotAuthoritative() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.List.Response(data: [ + .init( + id: "thread-summary", + turns: [ + .init( + id: "turn-summary", + status: "completed", + items: [ + .object([ + "id": .string("message-summary"), + "type": .string("agentMessage"), + "text": .string("Summary"), + ]), + ] + ), + ] + ), + ]), + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let page = try await server.listThreads() + + let snapshot = try #require(page.threads.first) + #expect(snapshot.turns?.first?.items.first?.id == "message-summary") + #expect(snapshot.turnItemsAreAuthoritative == false) + } + + @Test func threadSnapshotEqualityIgnoresTurnAuthorityFlag() { + let turns = [CodexTurnSnapshot(id: "turn-1", state: .completed)] + let publicSnapshot = CodexThreadSnapshot(id: "thread-1", turns: turns) + let summarySnapshot = CodexThreadSnapshot( + id: "thread-1", + turns: turns, + turnItemsAreAuthoritative: false + ) + + #expect(publicSnapshot == summarySnapshot) + } + + @Test func threadSnapshotClampsTurnAuthorityToLoadedItems() { + let partiallyLoadedSnapshot = CodexThreadSnapshot( + id: "thread-1", + turns: [ + CodexTurnSnapshot(id: "turn-full", state: .completed), + CodexTurnSnapshot( + id: "turn-summary", + state: .completed, + itemsLoadState: .summary + ), + ], + turnItemsAreAuthoritative: true + ) + #expect(partiallyLoadedSnapshot.turnItemsAreAuthoritative == false) + + let fullyLoadedSnapshot = CodexThreadSnapshot( + id: "thread-1", + turns: [CodexTurnSnapshot(id: "turn-full", state: .completed)], + turnItemsAreAuthoritative: true + ) + #expect(fullyLoadedSnapshot.turnItemsAreAuthoritative) + + let turnlessSnapshot = CodexThreadSnapshot( + id: "thread-1", + turnItemsAreAuthoritative: true + ) + #expect(turnlessSnapshot.turnItemsAreAuthoritative == false) + } + + @Test func threadStatusTreatsNonProtocolValuesAsUnknown() { + #expect(CodexThreadStatus(rawValue: "notLoaded") == .notLoaded) + #expect(CodexThreadStatus(rawValue: "idle") == .idle) + #expect(CodexThreadStatus(rawValue: "systemError") == .systemError) + #expect(CodexThreadStatus(rawValue: "active") == .active(activeFlags: [])) + #expect(CodexThreadStatus(rawValue: "loaded") == .unknown(rawValue: "loaded")) + #expect(CodexThreadStatus(rawValue: "loaded").isActive == false) + #expect(CodexThreadStatus(rawValue: "running") == .unknown(rawValue: "running")) + #expect(CodexThreadStatus(rawValue: "running").isActive == false) + #expect(CodexThreadStatus(rawValue: "closed") == .unknown(rawValue: "closed")) + } + + @Test func turnStatusCanonicalizesActiveAliases() { + #expect(CodexTurnStatus.inProgress.rawValue == "inProgress") + for rawValue in ["inProgress", "started", "running"] { + #expect(CodexTurnStatus(rawValue: rawValue) == .inProgress) + } + for rawValue in ["success", "succeeded", "cancelled", "aborted"] { + #expect(CodexTurnStatus(rawValue: rawValue) == .unknown(rawValue: rawValue)) + } + } + + @Test func terminalClassifierPreservesEveryCurrentV2OutcomeAndTiming() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + + func makeTurn(_ id: CodexTurnID) async -> CodexTurn { + let state = await harness.turnReplayStore.restoreGeneration( + turnID: id, + initialSnapshot: .init(id: id, state: .inProgress), + connectionLease: harness.lease + ) + return CodexTurn( + id: id, + threadID: "thread-1", + client: client, + router: router, + turnReplayStore: harness.turnReplayStore, + state: state + ) + } + + let completed = await makeTurn("turn-completed") + let interrupted = await makeTurn("turn-interrupted") + let failed = await makeTurn("turn-failed") + let future = await makeTurn("turn-future") + let inProgress = await makeTurn("turn-in-progress") + + let completedTask = Task { try await completed.result() } + let interruptedTask = Task { try await interrupted.result() } + let failedTask = Task { try await failed.result() } + let futureTask = Task { try await future.result() } + let inProgressTask = Task { try await inProgress.result() } + + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-completed", + status: "completed", + startedAt: 1_700_000_000, + completedAt: 1_700_000_002, + durationMS: 2_000 + )) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-interrupted", status: "interrupted")) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-failed", + status: "failed", + error: .init(message: "failed", codexErrorInfo: .serverOverloaded) + )) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-future", + status: "futureStatus", + error: .init( + message: "future failure", + codexErrorInfo: .httpConnectionFailed(httpStatusCode: 503), + additionalDetails: "upstream detail" + ) + )) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-in-progress", status: "inProgress")) + ) + + let completedOutcome = try await completedTask.value + guard case .completed(let response) = completedOutcome else { + Issue.record("Expected completed outcome.") + return + } + #expect(response.startedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(response.completedAt == Date(timeIntervalSince1970: 1_700_000_002)) + #expect(response.duration == .milliseconds(2_000)) + let interruptedOutcome = try await interruptedTask.value + guard case .interrupted(let interruptedResponse) = interruptedOutcome else { + Issue.record("Expected interrupted outcome.") + return + } + #expect(interruptedResponse.turnID == "turn-interrupted") + + guard case .failed(let failedTurn) = try await failedTask.value else { + Issue.record("Expected failed outcome.") + return + } + #expect(failedTurn.error == .init(message: "failed", info: .serverOverloaded)) + + guard case .invalidTerminalStatus(let rawStatus, let error, _) = try await futureTask.value else { + Issue.record("Expected future terminal status to remain invalid.") + return + } + #expect(rawStatus == "futureStatus") + #expect(error == .init( + message: "future failure", + info: .httpConnectionFailed(httpStatusCode: 503), + additionalDetails: "upstream detail" + )) + + guard case .invalidTerminalStatus(let rawStatus, let error, _) = try await inProgressTask.value else { + Issue.record("Expected in-progress terminal status to remain invalid.") + return + } + #expect(rawStatus == "inProgress") + #expect(error == nil) + } + + @Test func malformedTerminalPayloadsNeverBecomeOutcomes() async throws { + let cases: [(CodexTurnID, String)] = [ + ("turn-missing-status", #"{"turn":{"id":"turn-missing-status"}}"#), + ("turn-failed-without-error", #"{"turn":{"id":"turn-failed-without-error","status":"failed"}}"#), + ("turn-completed-with-error", #"{"turn":{"id":"turn-completed-with-error","status":"completed","error":{"message":"illegal"}}}"#), + ("turn-malformed", #"{"turnId":"turn-malformed","turn":"not-an-object"}"#), + ] + for (turnID, json) in cases { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let connectionEvents = await harness.server.connectionEvents() + let terminal = Task { await firstConnectionTermination(in: connectionEvents) } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotificationJSON(method: "turn/completed", json: json) + let termination = try #require(await terminal.value) + guard case .transportFailure(.protocolViolation(_, let rawData)) = termination else { + Issue.record("Expected connection protocol violation for \(turnID.rawValue).") + continue + } + #expect(rawData == Data(json.utf8)) + } + } + + @Test func terminalOutcomeAndConnectionFailureReplayToLateSubscribers() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + + let terminalTurnID = CodexTurnID(rawValue: "turn-terminal-replay") + let state = await harness.turnReplayStore.restoreGeneration( + turnID: terminalTurnID, + initialSnapshot: .init(id: terminalTurnID, state: .inProgress), + connectionLease: harness.lease + ) + let firstTerminalStream = try await harness.turnReplayStore.events( + for: terminalTurnID, + state: state + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-terminal-replay", status: "completed")) + ) + let firstTerminalEvents = try await collect(firstTerminalStream) + let lateTerminalEvents = try await collect( + try await harness.turnReplayStore.events(for: terminalTurnID, state: state) + ) + #expect(Array(firstTerminalEvents.suffix(lateTerminalEvents.count)) == lateTerminalEvents) + #expect(lateTerminalEvents.count == 2) + guard case .terminal(let terminalOutcome) = lateTerminalEvents.last else { + Issue.record("Expected a replayed terminal outcome.") + return + } + #expect(terminalOutcome.response.turnID == terminalTurnID) + + let connectionEvents = await harness.server.connectionEvents() + let firstFailure = Task { await firstConnectionTermination(in: connectionEvents) } + await connectionEvents.waitUntilNextSuspendsForTesting() + try await transport.emitServerNotificationJSON( + method: "turn/completed", + json: #"{"turn":{"id":"turn-failure-replay"}}"# + ) + let firstTermination = await firstFailure.value + let lateTermination = await firstConnectionTermination( + in: await harness.server.connectionEvents() + ) + #expect(firstTermination == lateTermination) + guard case .transportFailure(.protocolViolation) = firstTermination else { + Issue.record("Expected replayed connection protocol violation.") + return + } + } + + @Test func currentV2ThreadSessionSourcesRoundTripLosslessly() throws { + let sources: [AppServerAPI.Thread.SessionSource] = [ + .cli, + .vscode, + .exec, + .appServer, + .custom("automation"), + .subAgent(.review), + .subAgent(.compact), + .subAgent(.threadSpawn(.init( + parentThreadID: "parent-thread", + depth: 2, + agentPath: "reviewer/worker", + agentNickname: "Scout", + agentRole: "reviewer" + ))), + .subAgent(.memoryConsolidation), + .subAgent(.other("custom-agent")), + .unknown, + ] + let expectedDomainSources: [CodexThreadSessionSource] = [ + .cli, + .vscode, + .exec, + .appServer, + .custom("automation"), + .subAgent(.review), + .subAgent(.compact), + .subAgent(.threadSpawn(.init( + parentThreadID: "parent-thread", + depth: 2, + agentPath: "reviewer/worker", + agentNickname: "Scout", + agentRole: "reviewer" + ))), + .subAgent(.memoryConsolidation), + .subAgent(.other("custom-agent")), + .unknown, + ] + + for (source, expectedDomainSource) in zip(sources, expectedDomainSources) { + let data = try JSONEncoder().encode(source) + #expect(try JSONDecoder().decode(AppServerAPI.Thread.SessionSource.self, from: data) == source) + let snapshot = CodexAppServer.threadSnapshot( + from: .init(id: "thread-source", source: source), + includesTurns: false + ) + #expect(snapshot.source == expectedDomainSource) + #expect(snapshot.sourceKind == expectedDomainSource.sourceKind) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.sourceKind) == false) + } + } + + @Test func threadListProjectsCanonicalSubAgentReviewSource() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + #"{"data":[{"id":"thread-review","source":{"subAgent":"review"}}]}"#, + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + let snapshot = try #require(try await harness.server.listThreads().threads.first) + + #expect(snapshot.source == .subAgent(.review)) + #expect(snapshot.sourceKind == .subAgentReview) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.sourceKind) == false) + } + + @Test func threadListProjectsThreadProvenanceMetadata() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "data": [ + { + "id": "thread-worker", + "sessionId": "session-review", + "parentThreadId": "thread-parent", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "thread-parent", + "depth": 2, + "agent_path": "reviewer/worker", + "agent_nickname": "Scout", + "agent_role": "reviewer" + } + } + }, + "gitInfo": { + "sha": "0123456789abcdef", + "branch": "agent/review-sidebar", + "originUrl": "git@github.com:lynnswap/CodexKit.git" + } + } + ] + } + """, + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + let snapshot = try #require(try await harness.server.listThreads().threads.first) + + #expect(snapshot.sessionID == "session-review") + #expect(snapshot.parentThreadID == "thread-parent") + #expect(snapshot.source == .subAgent(.threadSpawn(.init( + parentThreadID: "thread-parent", + depth: 2, + agentPath: "reviewer/worker", + agentNickname: "Scout", + agentRole: "reviewer" + )))) + #expect(snapshot.sourceKind == .subAgentThreadSpawn) + #expect(snapshot.gitInfo == .init( + sha: "0123456789abcdef", + branch: "agent/review-sidebar", + originURL: "git@github.com:lynnswap/CodexKit.git" + )) + #expect(snapshot.hasField(.sessionID)) + #expect(snapshot.hasField(.parentThreadID)) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.gitInfo)) + } + + @Test func threadListDoesNotUseLegacySourceKindFallback() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + #"{"data":[{"id":"thread-legacy","sourceKind":"appServer"}]}"#, + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + let snapshot = try #require(try await harness.server.listThreads().threads.first) + + #expect(snapshot.sourceKind == nil) + #expect(snapshot.source == nil) + #expect(snapshot.hasField(.source) == false) + #expect(snapshot.hasField(.sourceKind) == false) + } + + @Test func threadSnapshotsTrackOmittedAndNullFields() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "data": [ + { + "id": "thread-partial", + "sessionId": null, + "parentThreadId": null, + "name": null, + "source": null, + "gitInfo": null, + "updatedAt": 1000 + } + ] + } + """, + for: "thread/list" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let page = try await server.listThreads() + let snapshot = try #require(page.threads.first) + + #expect(snapshot.hasField(.name)) + #expect(snapshot.name == nil) + #expect(snapshot.hasField(.sessionID)) + #expect(snapshot.sessionID == nil) + #expect(snapshot.hasField(.parentThreadID)) + #expect(snapshot.parentThreadID == nil) + #expect(snapshot.hasField(.source)) + #expect(snapshot.source == nil) + #expect(snapshot.sourceKind == nil) + #expect(snapshot.hasField(.gitInfo)) + #expect(snapshot.gitInfo == nil) + #expect(snapshot.hasField(.updatedAt)) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 1000)) + #expect(!snapshot.hasField(.workspace)) + #expect(!snapshot.hasField(.modelProvider)) + } + + @Test func threadSnapshotEncodingPreservesPresentNullFields() throws { + let snapshot = AppServerAPI.Thread.Snapshot( + id: "thread-partial", + name: nil, + updatedAt: nil, + presentFields: [ + .sessionID, + .parentThreadID, + .name, + .source, + .gitInfo, + .updatedAt, + ] + ) + + let data = try JSONEncoder().encode(snapshot) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["id"] as? String == "thread-partial") + #expect(object["sessionId"] is NSNull) + #expect(object["parentThreadId"] is NSNull) + #expect(object["name"] is NSNull) + #expect(object["source"] is NSNull) + #expect(object["gitInfo"] is NSNull) + #expect(object["updatedAt"] is NSNull) + #expect(object["cwd"] == nil) + } + + @Test func threadSnapshotEncodingPreservesThreadProvenanceMetadata() throws { + let snapshot = AppServerAPI.Thread.Snapshot( + id: "thread-worker", + sessionID: "session-review", + parentThreadID: "thread-parent", + source: .subAgent(.review), + gitInfo: .init( + sha: "0123456789abcdef", + branch: "agent/review-sidebar", + originURL: "git@github.com:lynnswap/CodexKit.git" + ) + ) + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(AppServerAPI.Thread.Snapshot.self, from: data) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gitInfo = try #require(object["gitInfo"] as? [String: Any]) + + #expect(decoded == snapshot) + #expect(object["sessionId"] as? String == "session-review") + #expect(object["parentThreadId"] as? String == "thread-parent") + #expect(gitInfo["originUrl"] as? String == "git@github.com:lynnswap/CodexKit.git") + } + + @Test func exactThreadSourceOwnsCompatibilityProjection() { + var snapshot = CodexThreadSnapshot( + id: "thread-source-owner", + source: .subAgent(.review) + ) + + #expect(snapshot.source == .subAgent(.review)) + #expect(snapshot.sourceKind == .subAgentReview) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.sourceKind) == false) + + snapshot.sourceKind = .subAgentReview + #expect(snapshot.source == .subAgent(.review)) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.sourceKind) == false) + + snapshot.sourceKind = .appServer + #expect(snapshot.source == nil) + #expect(snapshot.sourceKind == .appServer) + #expect(snapshot.hasField(.source) == false) + #expect(snapshot.hasField(.sourceKind)) + + snapshot.source = .custom("automation") + #expect(snapshot.source == .custom("automation")) + #expect(snapshot.sourceKind == nil) + #expect(snapshot.hasField(.source)) + #expect(snapshot.hasField(.sourceKind) == false) + + snapshot.source = nil + #expect(snapshot.source == nil) + #expect(snapshot.hasField(.source)) + } + + @Test func threadReadUsesIncludeTurnsToInterpretEmptyTurns() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Thread.Read.Response(thread: .init(id: "thread-empty", turns: [])), + for: "thread/read" + ) + try await transport.enqueue( + AppServerAPI.Thread.Read.Response(thread: .init(id: "thread-empty", turns: [])), + for: "thread/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let thread = CodexThread( + id: .init(rawValue: "thread-empty"), + client: client, + router: harness.router, + connectionLease: harness.lease + ) + + let metadataOnly = try await thread.read(includeTurns: false) + let withTurns = try await thread.read(includeTurns: true) + + #expect(metadataOnly.turns == nil) + #expect(!metadataOnly.hasField(.turns)) + #expect(withTurns.turns == []) + #expect(withTurns.hasField(.turns)) + } + + @Test func threadReadTreatsOmittedTurnsAsEmptyWhenIncluded() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "thread": { + "id": "thread-empty" + } + } + """, + for: "thread/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let thread = CodexThread( + id: .init(rawValue: "thread-empty"), + client: client, + router: harness.router, + connectionLease: harness.lease + ) + + let snapshot = try await thread.read(includeTurns: true) + + #expect(snapshot.turns == []) + #expect(snapshot.hasField(.turns)) + } + + @Test func threadReadDoesNotTreatSummaryTurnsAsAuthoritative() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "thread": { + "id": "thread-summary", + "turns": [ + { + "id": "turn-summary", + "status": "completed", + "itemsView": "summary", + "items": [ + { + "id": "message-summary", + "type": "agentMessage", + "text": "Summary" + } + ] + } + ] + } + } + """, + for: "thread/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let thread = CodexThread( + id: .init(rawValue: "thread-summary"), + client: client, + router: harness.router, + connectionLease: harness.lease + ) + + let snapshot = try await thread.read(includeTurns: true) + + #expect(snapshot.turns?.first?.itemsLoadState == .summary) + #expect(snapshot.turnItemsAreAuthoritative == false) + } + + @Test func threadReadPreservesItemsWithoutStableIDs() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "thread": { + "id": "thread-missing-item-id", + "turns": [ + { + "id": "turn-missing-item-id", + "status": "completed", + "items": [ + { + "type": "diagnostic", + "text": "Legacy diagnostic" + } + ] + } + ] + } + } + """, + for: "thread/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let thread = CodexThread( + id: .init(rawValue: "thread-missing-item-id"), + client: client, + router: harness.router, + connectionLease: harness.lease + ) + + let snapshot = try await thread.read(includeTurns: true) + let item = try #require(snapshot.turns?.first?.items.first) + + #expect(item.id.hasPrefix("missing-id:diagnostic:")) + #expect(item.text == "Legacy diagnostic") + #expect(item.rawPayload != nil) + } + + @Test func threadStoreDrivesRuntimeThreadStubsAfterStart() async throws { + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + let initial = try makeRuntimeStoredThreadFixture( + id: "thread-a", + workspace: workspace, + name: "A" + ) + let store = try CodexAppServerTestThreadStore(threads: [initial]) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + + let firstPage = try await runtime.server.listThreads() + #expect(firstPage.threads.map(\.id) == [initial.snapshot.id]) + #expect(firstPage.threads.allSatisfy { $0.turns == nil }) + #expect(firstPage.nextCursor == nil) + #expect(firstPage.backwardsCursor == nil) + + let updated = try makeRuntimeStoredThreadFixture( + id: "thread-b", + workspace: workspace, + name: "B", + preview: "Updated", + turns: [try makeRuntimeTestTurnFixture(id: "turn-b")] + ) + await store.upsert(updated) + + #expect(await store.storedThread(id: "thread-b") == updated) + + let secondPage = try await runtime.server.listThreads() + #expect(secondPage.threads.map(\.id) == [updated.snapshot.id, initial.snapshot.id]) + #expect(secondPage.threads.map(\.preview) == [updated.snapshot.preview, initial.snapshot.preview]) + #expect(secondPage.threads.allSatisfy { $0.turns == nil }) + + let resumed = try await runtime.server.resumeThread("thread-b") + let read = try await resumed.read(includeTurns: true) + #expect(read == updated.snapshot) + + await store.remove(id: "thread-a") + let removedPage = try await runtime.server.listThreads() + #expect(removedPage.threads.map(\.id) == [updated.snapshot.id]) + #expect(removedPage.threads.first?.turns == nil) + + let startedWorkspace = URL(fileURLWithPath: "/tmp/started", isDirectory: true) + let plannedStart = try makeRuntimeStoredThreadFixture( + id: "thread-started", + workspace: startedWorkspace, + model: "gpt-5", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 100), + recencyAt: Date(timeIntervalSince1970: 100), + ephemeral: true + ) + try await store.enqueueStart(plannedStart) + let started = try await runtime.server.startThread( + in: startedWorkspace, + options: .init(model: "gpt-5", modelProvider: "openai", ephemeral: true) + ) + let startedSnapshot = try #require(await store.storedThread(id: started.id)).snapshot + #expect(startedSnapshot.workspace == startedWorkspace) + #expect(startedSnapshot.modelProvider == "openai") + #expect(startedSnapshot.ephemeral == true) + #expect(started.model == "gpt-5") + } + + @Test func threadStoreThreadReadHonorsIncludeTurns() async throws { + let stored = try makeRuntimeStoredThreadFixture( + id: "thread-with-turns", + turns: [try makeRuntimeTestTurnFixture(id: "turn-from-store")] + ) + let runtime = try await CodexAppServerTestRuntime.start(threads: [stored]) + let thread = try await runtime.server.resumeThread("thread-with-turns") + + let metadataOnly = try await thread.read(includeTurns: false) + let withTurns = try await thread.read(includeTurns: true) + + #expect(metadataOnly.turns == nil) + #expect(!metadataOnly.hasField(.turns)) + #expect(withTurns.turns?.map(\.id.rawValue) == ["turn-from-store"]) + #expect(withTurns.hasField(.turns)) + } + + @Test func threadStoreHonorsThreadListPagination() async throws { + let threads = try [ + makeRuntimeStoredThreadFixture(id: "thread-a", name: "A"), + makeRuntimeStoredThreadFixture(id: "thread-b", name: "B"), + makeRuntimeStoredThreadFixture(id: "thread-c", name: "C"), + ] + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + + let firstPage = try await runtime.server.listThreads(.init(limit: 2)) + #expect(firstPage.threads.map(\.id.rawValue) == ["thread-a", "thread-b"]) + let nextCursor = try #require(firstPage.nextCursor) + #expect(firstPage.backwardsCursor == nil) + + let secondPage = try await runtime.server.listThreads(.init( + cursor: nextCursor, + limit: 2 + )) + #expect(secondPage.threads.map(\.id.rawValue) == ["thread-c"]) + #expect(secondPage.nextCursor == nil) + #expect(secondPage.backwardsCursor != nil) + } + + @Test func threadStoreOwnsArchiveMembershipSortingAndMutations() async throws { + let activeOld = try makeRuntimeStoredThreadFixture( + id: "active-old", + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 40), + recencyAt: Date(timeIntervalSince1970: 20) + ) + let activeNew = try makeRuntimeStoredThreadFixture( + id: "active-new", + createdAt: Date(timeIntervalSince1970: 30), + updatedAt: Date(timeIntervalSince1970: 20), + recencyAt: Date(timeIntervalSince1970: 50) + ) + let archived = try makeRuntimeStoredThreadFixture( + id: "archived", + createdAt: Date(timeIntervalSince1970: 60), + updatedAt: Date(timeIntervalSince1970: 60), + recencyAt: Date(timeIntervalSince1970: 60), + isArchived: true + ) + let store = try CodexAppServerTestThreadStore(threads: [activeOld, activeNew, archived]) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + + #expect(try await runtime.server.listThreads().threads.map(\.id) == [ + activeNew.snapshot.id, + activeOld.snapshot.id, + ]) + #expect(try await runtime.server.listThreads(.init( + archived: true + )).threads.map(\.id) == [archived.snapshot.id]) + #expect(try await runtime.server.listThreads(.init( + sortDirection: .ascending, + sortKey: .updatedAt + )).threads.map(\.id) == [activeNew.snapshot.id, activeOld.snapshot.id]) + + try await runtime.server.archiveThread(activeNew.snapshot.id) + #expect(try await runtime.server.listThreads().threads.map(\.id) == [activeOld.snapshot.id]) + #expect(try await runtime.server.listThreads(.init( + archived: true, + sortDirection: .ascending, + sortKey: .createdAt + )).threads.map(\.id) == [activeNew.snapshot.id, archived.snapshot.id]) + + _ = try await runtime.server.unarchiveThread(activeNew.snapshot.id) + try await runtime.server.deleteThread(activeOld.snapshot.id) + #expect(try await runtime.server.listThreads().threads.map(\.id) == [activeNew.snapshot.id]) + #expect(await store.storedThread(id: activeOld.snapshot.id) == nil) + await runtime.close() + } + + @Test func threadStoreAndQueuedThreadResponsesAreMutuallyExclusive() async throws { + let storeRuntime = try await CodexAppServerTestRuntime.start( + threadStore: try CodexAppServerTestThreadStore() + ) + await #expect(throws: CodexAppServerTestError.invalidFixture( + "Cannot enqueue thread/list while an authoritative thread store owns thread state." + )) { + try await storeRuntime.transport.enqueueThreadList( + CodexAppServerTestThreadPage(threads: []) + ) + } + await storeRuntime.close() + + let queuedTransport = CodexAppServerTestTransport() + try await queuedTransport.enqueueThreadList(CodexAppServerTestThreadPage(threads: [])) + await #expect(throws: CodexAppServerTestError.invalidFixture( + "Queued thread responses and an authoritative thread store are mutually exclusive." + )) { + try await queuedTransport.stubThreads(try CodexAppServerTestThreadStore()) + } + await queuedTransport.close() + } + + @Test func authoritativeThreadStoreNeverFabricatesAnUnplannedStart() async throws { + let store = try CodexAppServerTestThreadStore() + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + + do { + _ = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/unplanned", isDirectory: true) + ) + Issue.record("Expected an unplanned thread/start to be rejected.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind else { + Issue.record("Expected a typed server rejection, got \(error).") + return + } + #expect(serverError.code == -32602) + #expect(serverError.message == "thread/start requires an explicitly planned test thread.") + } + #expect(await store.storedThread(id: "unplanned") == nil) + await runtime.close() + } + + @Test func authoritativeThreadStoreConsumesPlannedForkWithoutMutatingSource() async throws { + let source = try makeRuntimeStoredThreadFixture( + id: "fork-source", + name: "Source" + ) + let fork = try makeRuntimeStoredThreadFixture( + id: "fork-result", + name: "Fork", + ephemeral: false, + forkedFromID: source.snapshot.id + ) + let store = try CodexAppServerTestThreadStore(threads: [source]) + try await store.enqueueFork(fork, from: source.snapshot.id) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + + let handle = try await runtime.server.forkThread( + source.snapshot.id, + options: .init(modelProvider: "openai", ephemeral: false) + ) + + #expect(handle.id == fork.snapshot.id) + #expect(await store.storedThread(id: source.snapshot.id) == source) + #expect(await store.storedThread(id: fork.snapshot.id) == fork) + do { + _ = try await runtime.server.forkThread(source.snapshot.id) + Issue.record("Expected an unplanned thread/fork to be rejected.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind else { + Issue.record("Expected a typed server rejection, got \(error).") + return + } + #expect(serverError.code == -32602) + } + #expect(await store.storedThread(id: source.snapshot.id) == source) + await runtime.close() + } + + @Test func threadStoreHonorsThreadTurnListPagination() async throws { + let turns = try [ + makeRuntimeTestTurnFixture(id: "turn-a"), + makeRuntimeTestTurnFixture(id: "turn-b"), + makeRuntimeTestTurnFixture(id: "turn-c"), + ] + let runtime = try await CodexAppServerTestRuntime.start(threads: [ + makeRuntimeStoredThreadFixture(id: "thread-turns", turns: turns) + ]) + let thread = try await runtime.server.resumeThread("thread-turns") + + let firstPage = try await thread.listTurns(.init(limit: 2)) + #expect(firstPage.turns.map(\.id.rawValue) == ["turn-a", "turn-b"]) + let nextCursor = try #require(firstPage.nextCursor) + #expect(firstPage.backwardsCursor == nil) + + let secondPage = try await thread.listTurns(.init( + cursor: nextCursor, + limit: 2 + )) + #expect(secondPage.turns.map(\.id.rawValue) == ["turn-c"]) + #expect(secondPage.nextCursor == nil) + #expect(secondPage.backwardsCursor != nil) + } + + @Test func transportStubThreadsAcceptsMutableThreadStore() async throws { + let store = try CodexAppServerTestThreadStore() + let transport = CodexAppServerTestTransport() + try await transport.stubThreads(store) + let runtime = try await CodexAppServerTestRuntime.start(transport: transport) + + let stored = try makeRuntimeStoredThreadFixture( + id: "thread-transport", + name: "Transport" + ) + await store.upsert(stored) + + let page = try await runtime.server.listThreads() + #expect(page.threads.map(\.id) == [stored.snapshot.id]) + #expect(page.threads.first?.name == stored.snapshot.name) + #expect(page.threads.first?.turns == nil) + } + + @Test func appServerArchiveThreadSerializesThreadID() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueEmpty(for: "thread/archive") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + try await server.archiveThread("thread-archive") + + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "thread/archive") + let params = try JSONDecoder().decode( + AppServerAPI.Thread.Archive.Params.self, + from: request.params + ) + #expect(params.threadID == "thread-archive") + } + + @Test func threadStartReviewSerializesTargetAndStreamsReviewEvents() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + #expect(review.threadID == "thread-1") + #expect(review.turnID == "turn-review") + #expect(review.reviewThreadID == "thread-review") + + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "review/start") + let params = try JSONDecoder().decode( + AppServerAPI.Review.Start.Params.self, + from: request.params + ) + #expect(params.threadID == "thread-1") + #expect(params.target == .baseBranch("main")) + #expect(params.delivery == .detached) + + var eventIterator = review.events.makeAsyncIterator() + let initialEvent = try #require(try await eventIterator.next()) + #expect(initialEvent == .snapshot(review.initialTurn)) + let eventsTask = Task { + var events = [initialEvent] + while let event = try await eventIterator.next() { + events.append(event) + } + return events + } + + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "passed", + status: "completed" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "reasoning-1", + type: "reasoning", + summary: ["Checked the diff"], + content: ["trace"] + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "tool-1", + type: "mcpToolCall", + text: "ok", + status: "completed", + tool: "review_read" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init( + id: "file-1", + type: "fileChange", + status: "completed", + changes: .array([ + .object([ + "path": .string("Sources/File.swift"), + "kind": .object(["type": .string("update")]), + "diff": .string("updated"), + ]), + ]) + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review", + turn: .init(id: "turn-review", status: "completed") + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let events = try await eventsTask.value + let completedItems = events.compactMap { event -> (item: CodexThreadItem, turnID: CodexTurnID?)? in + guard case .itemCompleted(let item, let turnID) = event else { + return nil + } + return (item, turnID) + } + #expect(completedItems.count == 4) + #expect(completedItems.first?.turnID == "turn-review") + #expect(completedItems.first?.item.kind == .commandExecution) + #expect(completedItems.first?.item.text == "passed") + #expect(completedItems.contains { + if case .reasoning(let reasoning) = $0.item.content { + reasoning.summary == ["Checked the diff"] + } else { + false + } + }) + #expect(completedItems.contains { + if case .toolCall(let toolCall) = $0.item.content { + toolCall.name == "review_read" + } else { + false + } + }) + #expect(completedItems.contains { + if case .fileChange(let fileChange) = $0.item.content { + fileChange.path == "Sources/File.swift" + } else { + false + } + }) + + let logs = try await collect(review.logEntries) + #expect(logs.map(\.id) == ["command-1", "reasoning-1", "tool-1", "file-1"]) + #expect(logs.allSatisfy { $0.turnID == "turn-review" }) + #expect(logs.contains { + if case .command(let command) = $0.item?.content { + command.command == "swift test" + } else { + false + } + }) + #expect(logs.contains { + if case .toolCall(let toolCall) = $0.item?.content { + toolCall.name == "review_read" + } else { + false + } + }) + #expect(logs.contains { + if case .fileChange(let fileChange) = $0.item?.content { + fileChange.path == "Sources/File.swift" + } else { + false + } + }) + } + + @Test func reviewSessionExposesPersistableLifecycleIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + + let thread = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + options: .init(model: "gpt-5") + ) + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + #expect(review.identity == identity) + #expect(review.model == nil) + #expect(review.sourceThreadID == "thread-source") + #expect(review.activeTurnThreadID == "thread-review") + #expect(review.associatedThreadIDs == ["thread-source", "thread-review"]) + #expect(review.cleanupThreadIDs == ["thread-review", "thread-source"]) + #expect(identity.associatedThreadIDs == ["thread-source", "thread-review"]) + #expect(identity.cleanupThreadIDs == ["thread-review", "thread-source"]) + + let encoded = try JSONEncoder().encode(identity) + let decoded = try JSONDecoder().decode(CodexReviewIdentity.self, from: encoded) + #expect(decoded == identity) + } + + @Test func inlineReviewIdentityKeepsDetachedReviewThreadNil() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-source", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-source" + ) + + let thread = try await runtime.server.startThread( + in: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + options: .init(model: "gpt-5") + ) + let review = try await thread.startReview(target: .baseBranch("main")) + + #expect(review.reviewThreadID == "thread-source") + #expect(review.identity == CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + model: "gpt-5" + )) + #expect(review.identity.reviewThreadID == nil) + #expect(review.identity.activeTurnThreadID == "thread-source") + #expect(review.identity.cleanupThreadIDs == ["thread-source"]) + } + + @Test func appServerResumeReviewRestoresEventsAndCancellationFromIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-review", + workspace: URL(fileURLWithPath: "/tmp/project", isDirectory: true) + )) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + + let review = try await runtime.server.resumeReview(identity) + let cancellation = try await review.cancel() + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-review", status: "completed")) + ) + + #expect(review.identity == identity) + #expect(cancellation.threadID == "thread-review") + #expect(cancellation.turnID == "turn-review") + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/resume", + "turn/interrupt", + ]) + let resumeParams = try requests[1].decodeParams(AppServerAPI.Thread.Resume.Params.self) + #expect(resumeParams.threadID == "thread-review") + #expect(resumeParams.model == "gpt-5") + let interruptParams = try requests[2].decodeParams(AppServerAPI.Turn.Interrupt.Params.self) + #expect(interruptParams.threadID == "thread-review") + #expect(interruptParams.turnID == "turn-review") + + var iterator = review.events.makeAsyncIterator() + var terminalResponse: CodexResponse? + while let event = try await iterator.next() { + if case .terminal(.completed(let response)) = event { + terminalResponse = response + } + } + if let terminalResponse { + #expect(terminalResponse.turnID == "turn-review") + } else { + Issue.record("Expected resumed review.events to receive turn-only completion.") + } + } + + @Test func appServerResumeReviewUsesThreadOptionModelOverride() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume( + .init(id: "thread-review"), + model: "gpt-5.1" + ) + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + + let review = try await runtime.server.resumeReview( + identity, + threadOptions: .init(model: "gpt-5.1") + ) + + #expect(review.model == "gpt-5.1") + #expect(review.identity.model == "gpt-5.1") + let request = try #require(await runtime.transport.recordedRequests().last) + let params = try request.decodeParams(AppServerAPI.Thread.Resume.Params.self) + #expect(params.threadID == "thread-review") + #expect(params.model == "gpt-5.1") + } + + @Test func appServerResumeReviewPromotesTheResponseBaselineToItsPersistedTurnIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-review", + workspace: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + turns: [ + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [ + .init( + id: "response-message", + kind: .agentMessage, + content: .message(.init( + id: "response-message", + role: .assistant, + text: "From resume response" + )) + ), + ] + ), + ] + )) + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + + let review = try await runtime.server.resumeReview(identity) + let eventThread = await runtime.server.reviewEventThread( + for: review, + workspace: URL(fileURLWithPath: "/tmp/project", isDirectory: true) + ) + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let events = try await collect(eventThread.events) + #expect(events.containsThreadText("From resume response", turnID: "turn-review")) + #expect(events.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "rollout-synthesized-turn" + } + return false + } == false) + } + + @Test func reviewSessionCancelHookReceivesCurrentActiveTurn() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + await runtime.transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-review but found turn-new", + for: "turn/interrupt" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let recorder = CancellationRecorder() + + let review = try await runtime.server.resumeReview(identity) + let cancellation = try await review.cancel { cancellation in + await recorder.append(cancellation) + } + + #expect(cancellation.threadID == "thread-review") + #expect(cancellation.turnID == "turn-new") + #expect(await recorder.values() == [ + CodexTurnCancellation(threadID: "thread-review", turnID: "turn-new") + ]) + let turnIDs = try await runtime.transport.recordedRequests(method: "turn/interrupt") + .map { request in + try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(turnIDs == ["turn-review", "turn-new"]) + } + + @Test func threadTurnsListRequestUsesThreadScope() { + let request = AppServerAPI.Thread.Turns.List.Request(params: .init(threadID: "thread-1")) + + #expect(request.scope == .thread("thread-1")) + } + + @Test func appServerPrepareAndRestartReviewUsesLifecycleControlSequence() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-review-restarted" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-review", status: "interrupted")) + ) + let token = try await prepareTask.value + let review = try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main"), + delivery: .detached + ) + + #expect(token.interruptedIdentity == identity) + #expect(review.identity == CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-restarted", + reviewThreadID: "thread-review-restarted" + )) + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/resume", + "turn/interrupt", + "thread/resume", + "thread/rollback", + "thread/resume", + "review/start", + ]) + let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { + try $0.decodeParams(AppServerAPI.Thread.Resume.Params.self).threadID + } + #expect(resumeThreadIDs == ["thread-review", "thread-review", "thread-source"]) + let resumeModels = try requests.filter { $0.method == "thread/resume" }.map { + try $0.decodeParams(AppServerAPI.Thread.Resume.Params.self).model + } + #expect(resumeModels == ["gpt-5", "gpt-5", nil]) + let interrupt = try #require(requests.first { $0.method == "turn/interrupt" }) + let interruptParams = try interrupt.decodeParams(AppServerAPI.Turn.Interrupt.Params.self) + #expect(interruptParams.threadID == "thread-review") + #expect(interruptParams.turnID == "turn-review") + let rollback = try #require(requests.first { $0.method == "thread/rollback" }) + let rollbackParams = try rollback.decodeParams(AppServerAPI.Thread.Rollback.Params.self) + #expect(rollbackParams.threadID == "thread-review") + #expect(rollbackParams.numTurns == 1) + let reviewStart = try #require(requests.last) + let reviewStartParams = try reviewStart.decodeParams(AppServerAPI.Review.Start.Params.self) + #expect(reviewStartParams.threadID == "thread-source") + #expect(reviewStartParams.target == .baseBranch("main")) + #expect(reviewStartParams.delivery == .detached) + await runtime.close() + } + + @Test func prepareReviewRestartRejectsConcurrentPreparationForTheSameSource() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let interruptGate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + await runtime.transport.holdNextIgnoringCancellation( + method: "turn/interrupt", + gate: interruptGate + ) + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + let firstPreparation = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + firstPreparation.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt") + + await #expect(throws: CodexAppServerError.self) { + try await runtime.server.prepareReviewRestart(identity) + } + + await interruptGate.open() + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review", + turn: .init(id: "turn-review", status: "interrupted") + ) + ) + #expect(try await firstPreparation.value.interruptedIdentity == identity) + #expect(await runtime.transport.recordedRequests(method: "turn/interrupt").count == 1) + await runtime.close() + } + + @Test func prepareReviewRestartRetainsInputIdentityWhenResumeFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + await runtime.transport.enqueueFailure( + code: -32_000, + message: "resume failed", + for: "thread/resume" + ) + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + + await #expect(throws: CodexAppServerError.self) { + try await runtime.server.prepareReviewRestart(identity) + } + + #expect(await runtime.server.discardAllPreparedReviewRestarts() == [ + "thread-source": [identity], + ]) + await runtime.close() + } + + @Test func prepareInlineReviewRestartKeepsOuterThreadEventOwnerWhenInterruptRedirects() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + await runtime.transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-review but found turn-review-child", + for: "turn/interrupt" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + model: "gpt-5" + ) + + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt", count: 2) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review-child", + turn: .init(id: "turn-review-child", status: "interrupted") + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-source", + turnID: "turn-review", + item: .init( + id: "review-output", + type: "agentMessage", + text: "Review interrupted" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-source", + turn: .init(id: "turn-review", status: "interrupted") + ) + ) + + let token = try await prepareTask.value + #expect(token.interruptedIdentity == identity) + let turnIDs = try await runtime.transport + .recordedRequests(method: "turn/interrupt") + .map { request in + try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(turnIDs == ["turn-review", "turn-review-child"]) + await runtime.close() + } + + @Test func cleanupReviewKeepsCancellationRetryCleanupForPreparedRestart() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + await runtime.transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-review but found turn-new", + for: "turn/interrupt" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let restartedIdentity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-restarted", + reviewThreadID: "thread-review-restarted", + model: "gpt-5" + ) + + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt", count: 2) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-new", status: "interrupted")) + ) + _ = try await prepareTask.value + for _ in 0..<3 { + try await runtime.transport.enqueueEmpty(for: "thread/delete") + } + await runtime.server.cleanupReview(restartedIdentity) + + let interruptTurnIDs = try await runtime.transport.recordedRequests(method: "turn/interrupt").map { + try $0.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(interruptTurnIDs == ["turn-review", "turn-new"]) + let deletedThreadIDs = try await runtime.transport.recordedRequests(method: "thread/delete").map { + try $0.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + } + #expect(deletedThreadIDs == [ + "thread-review", + "thread-review-restarted", + "thread-source", + ]) + await runtime.close() + } + + @Test func cleanupReviewDeletesDetachedThreadsBeforeSourceAndDedupes() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + for _ in 0..<4 { + try await runtime.transport.enqueueEmpty(for: "thread/delete") + } + + let result = await runtime.server.cleanupReview( + identity, + additionalCleanupThreadIDs: [ + ["thread-source", "thread-extra", "thread-review"], + ["thread-extra", "thread-extra-2", "thread-source"], + ] + ) + + #expect(result == CodexReviewCleanupResult( + attemptedThreadIDs: [ + "thread-review", + "thread-extra", + "thread-extra-2", + "thread-source", + ], + failures: [] + )) + + let deletedThreadIDs = try await runtime.transport.recordedRequests(method: "thread/delete").map { + try $0.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + } + #expect(deletedThreadIDs == [ + "thread-review", + "thread-extra", + "thread-extra-2", + "thread-source", + ]) + await runtime.close() + } + + @Test func cleanupReviewRestoresRetainedIdentitiesAfterDeletionFailure() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review", + turn: .init(id: "turn-review", status: "interrupted") + ) + ) + _ = try await prepareTask.value + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "delete failed", + for: "thread/delete" + ) + for _ in 0..<3 { + try await runtime.transport.enqueueEmpty(for: "thread/delete") + } + let failedCleanup = await runtime.server.cleanupReview(identity) + #expect(failedCleanup.attemptedThreadIDs == ["thread-review", "thread-source"]) + #expect(failedCleanup.failures.map(\.threadID) == ["thread-review"]) + #expect(failedCleanup.failures.first?.message.contains("delete failed") == true) + + let retriedCleanup = await runtime.server.cleanupReview(identity) + #expect(retriedCleanup == CodexReviewCleanupResult( + attemptedThreadIDs: ["thread-review", "thread-source"], + failures: [] + )) + + let deletedThreadIDs = try await runtime.transport + .recordedRequests(method: "thread/delete") + .map { request in + try request.decodeParams(AppServerAPI.Thread.Delete.Params.self).threadID + } + #expect(deletedThreadIDs == [ + "thread-review", + "thread-source", + "thread-review", + "thread-source", + ]) + await runtime.close() + } + + @Test func restartPreparedReviewRejectsStaleTokenWithMeaningfulError() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let token = CodexReviewRestartToken( + id: "stale-token", + interruptedIdentity: .init(threadID: "thread-source", turnID: "turn-review") + ) + + do { + _ = try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + Issue.record("Expected stale restart token to throw.") + } catch let error as CodexAppServerError { + #expect(error == .reviewRestartUnavailable("stale-token")) + #expect(error.localizedDescription.contains("stale-token")) + } catch { + Issue.record("Expected CodexAppServerError, got \(error).") + } + await runtime.close() + } + + @Test func discardPreparedReviewRestartRequiresTheFullTokenAndTransfersOnce() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + let restarted = try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + let forged = CodexReviewRestartToken( + id: token.id, + interruptedIdentity: .init( + threadID: "thread-source", + turnID: "forged-turn", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + ) + + #expect(await runtime.server.discardPreparedReviewRestart(forged).isEmpty) + #expect(await runtime.server.discardPreparedReviewRestart(token) == [ + identity, + restarted.identity, + ]) + #expect(await runtime.server.discardPreparedReviewRestart(token).isEmpty) + await runtime.close() + } + + @Test func discardAllPreparedReviewRestartsReturnsOrderedSourceGroups() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let firstIdentity = CodexReviewIdentity( + threadID: "thread-source-a", + turnID: "turn-review-a", + reviewThreadID: "thread-review-a" + ) + let firstToken = try await prepareRestartToken( + runtime: runtime, + identity: firstIdentity + ) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review-a")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source-a")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted-a", + reviewThreadID: "thread-review-restarted-a" + ) + let restarted = try await runtime.server.restartPreparedReview( + firstToken, + target: .uncommittedChanges, + delivery: .detached + ) + + let secondIdentity = CodexReviewIdentity( + threadID: "thread-source-b", + turnID: "turn-review-b", + reviewThreadID: "thread-review-b" + ) + _ = try await prepareRestartToken( + runtime: runtime, + identity: secondIdentity + ) + + let retainedBySource = await runtime.server.discardAllPreparedReviewRestarts() + + #expect(retainedBySource == [ + "thread-source-a": [firstIdentity, restarted.identity], + "thread-source-b": [secondIdentity], + ]) + #expect(await runtime.server.discardAllPreparedReviewRestarts().isEmpty) + let resumeCount = await runtime.transport.recordedRequests(method: "thread/resume").count + await #expect(throws: CodexAppServerError.self) { + try await runtime.server.prepareReviewRestart(.init( + threadID: "thread-source-c", + turnID: "turn-review-c", + reviewThreadID: "thread-review-c" + )) + } + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == resumeCount) + await runtime.close() + } + + @Test func restartPreparedReviewKeepsTokenForRetryAfterPartialFailure() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest(method: "turn/interrupt") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-review", status: "interrupted")) + ) + let token = try await prepareTask.value + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + await runtime.transport.enqueueFailure( + code: -32000, + message: "source resume failed", + for: "thread/resume" + ) + do { + _ = try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + Issue.record("Expected source resume failure.") + } catch { + #expect(String(describing: error).contains("source resume failed")) + } + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + let review = try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + + #expect(review.identity.threadID == "thread-source") + #expect(review.identity.turnID == "turn-restarted") + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/resume", + "turn/interrupt", + "thread/resume", + "thread/rollback", + "thread/resume", + "thread/resume", + "review/start", + ]) + #expect(requests.filter { $0.method == "thread/rollback" }.count == 1) + let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { + try $0.decodeParams(AppServerAPI.Thread.Resume.Params.self).threadID + } + #expect(resumeThreadIDs == [ + "thread-review", + "thread-review", + "thread-source", + "thread-source", + ]) + } + + @Test func restartPreparedReviewExhaustsItsTwoInvocationBudget() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + await runtime.transport.enqueueFailure( + code: -32000, + message: "first source resume failed", + for: "thread/resume" + ) + await #expect(throws: CodexAppServerError.self) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + + await runtime.transport.enqueueFailure( + code: -32000, + message: "second source resume failed", + for: "thread/resume" + ) + await #expect(throws: CodexAppServerError.reviewRestartUnavailable(token.id)) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + await #expect(throws: CodexAppServerError.reviewRestartUnavailable(token.id)) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + + #expect(await runtime.transport.recordedRequests(method: "thread/rollback").count == 1) + let resumeThreadIDs = try await runtime.transport.recordedRequests(method: "thread/resume") + .map { request in + try request.decodeParams(AppServerAPI.Thread.Resume.Params.self).threadID + } + #expect(resumeThreadIDs == [ + "thread-review", + "thread-review", + "thread-source", + "thread-source", + ]) + } + + @Test func restartPreparedReviewInvalidatesAfterAmbiguousRollbackResponse() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueJSON( + "\"not-an-empty-response\"", + for: "thread/rollback" + ) + + await #expect(throws: CodexAppServerError.self) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + await #expect(throws: CodexAppServerError.reviewRestartUnavailable(token.id)) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + #expect(await runtime.server.discardPreparedReviewRestart(token) == [identity]) + #expect(await runtime.transport.recordedRequests(method: "thread/rollback").count == 1) + } + + @Test func restartPreparedReviewCancellationOnlyRemovesTheWaitingCaller() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + + let rollbackGate = CodexAppServerTestGate() + let sourceResumeGate = CodexAppServerTestGate() + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/rollback", + gate: rollbackGate + ) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + let restart = Task { + try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + } + defer { + restart.cancel() + } + + await runtime.transport.waitForRequest(method: "thread/rollback") + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/resume", + gate: sourceResumeGate + ) + await rollbackGate.open() + await runtime.transport.waitForRequest(method: "thread/resume", count: 3) + await runtime.server.waitForReviewRestartWaiterCountForTesting( + tokenID: token.id, + atLeast: 1 + ) + restart.cancel() + + do { + _ = try await restart.value + Issue.record("Expected cancelled source resume failure.") + } catch is CancellationError { + #expect(await runtime.transport.recordedRequests(method: "review/start").isEmpty) + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + + await sourceResumeGate.open() + let review = try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + + #expect(review.identity.threadID == "thread-source") + #expect(review.identity.turnID == "turn-restarted") + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/resume", + "turn/interrupt", + "thread/resume", + "thread/rollback", + "thread/resume", + "review/start", + ]) + #expect(requests.filter { $0.method == "thread/rollback" }.count == 1) + } + + @Test func discardPreparedReviewRestartCleansLateDetachedSessionWithoutDeletingThreads() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + let reviewStartGate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-review-restarted" + ) + await runtime.transport.holdNextIgnoringCancellation( + method: "review/start", + gate: reviewStartGate + ) + + let restart = Task { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main"), + delivery: .detached + ) + } + defer { + restart.cancel() + } + await runtime.transport.waitForRequest(method: "review/start") + let discard = Task { + await runtime.server.discardPreparedReviewRestart(token) + } + await runtime.server.waitForReviewRestartInvalidationRequestForTesting( + tokenID: token.id + ) + await reviewStartGate.open() + await runtime.transport.waitForRequest(method: "turn/interrupt", count: 2) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-restarted", status: "interrupted")) + ) + let retainedIdentities = await discard.value + + do { + _ = try await restart.value + Issue.record("Expected invalidated review start failure.") + } catch let error as CodexAppServerError { + #expect(error == .reviewRestartUnavailable(token.id)) + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.method) == [ + "initialize", + "thread/resume", + "turn/interrupt", + "thread/resume", + "thread/rollback", + "thread/resume", + "review/start", + "turn/interrupt", + ]) + } catch { + Issue.record("Expected CodexAppServerError, got \(error).") + } + #expect(retainedIdentities == [ + identity, + CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-restarted", + reviewThreadID: "thread-review-restarted" + ), + ]) + + do { + _ = try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + Issue.record("Expected cleaned up restart token to throw.") + } catch let error as CodexAppServerError { + #expect(error == .reviewRestartUnavailable(token.id)) + } catch { + Issue.record("Expected CodexAppServerError, got \(error).") + } + } + + @Test func discardPreparedReviewRestartAwaitsInlineOuterTerminalAfterRedirectedChild() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + let reviewStartGate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + await runtime.transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-restarted but found turn-review-child", + for: "turn/interrupt" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + await runtime.transport.holdNextIgnoringCancellation( + method: "review/start", + gate: reviewStartGate + ) + + let restart = Task { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + defer { + restart.cancel() + } + await runtime.transport.waitForRequest(method: "review/start") + let discard = Task { + await runtime.server.discardPreparedReviewRestart(token) + } + await runtime.server.waitForReviewRestartInvalidationRequestForTesting( + tokenID: token.id + ) + await reviewStartGate.open() + await runtime.transport.waitForRequest(method: "turn/interrupt", count: 3) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review-child", + turn: .init(id: "turn-review-child", status: "interrupted") + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-source", + turnID: "turn-restarted", + item: .init( + id: "review-output", + type: "agentMessage", + text: "Review interrupted" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-source", + turn: .init(id: "turn-restarted", status: "interrupted") + ) + ) + + let retainedIdentities = await discard.value + #expect(retainedIdentities == [ + identity, + CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-restarted", + model: "gpt-5" + ), + ]) + await #expect(throws: CodexAppServerError.reviewRestartUnavailable(token.id)) { + try await restart.value + } + #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) + await runtime.close() + } + + @Test func restartPreparedReviewJoinsConcurrentMatchingInvocation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + + let resumeGate = CodexAppServerTestGate() + await runtime.transport.holdNext(method: "thread/resume", gate: resumeGate) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + let firstRestart = Task { + try await runtime.server.restartPreparedReview(token, target: .baseBranch("main")) + } + defer { + firstRestart.cancel() + } + await runtime.transport.waitForRequest(method: "thread/resume", count: 2) + + let secondRestart = Task { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + defer { + secondRestart.cancel() + } + await runtime.server.waitForReviewRestartWaiterCountForTesting( + tokenID: token.id, + atLeast: 2 + ) + + await resumeGate.open() + let firstReview = try await firstRestart.value + let secondReview = try await secondRestart.value + + #expect(firstReview.identity == secondReview.identity) + #expect(firstReview.turnID == "turn-restarted") + let requests = await runtime.transport.recordedRequests() + #expect(requests.filter { $0.method == "thread/rollback" }.count == 1) + #expect(requests.filter { $0.method == "review/start" }.count == 1) + let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { + try $0.decodeParams(AppServerAPI.Thread.Resume.Params.self).threadID + } + #expect(resumeThreadIDs == ["thread-review", "thread-review", "thread-source"]) + } + + @Test func restartPreparedReviewRejectsConcurrentDifferentInvocation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review", + model: "gpt-5" + ) + let token = try await prepareRestartToken(runtime: runtime, identity: identity) + let resumeGate = CodexAppServerTestGate() + await runtime.transport.holdNext(method: "thread/resume", gate: resumeGate) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + try await runtime.transport.enqueueEmpty(for: "thread/rollback") + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-restarted", + reviewThreadID: "thread-source" + ) + let restart = Task { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("main") + ) + } + defer { + restart.cancel() + } + await runtime.transport.waitForRequest(method: "thread/resume", count: 2) + await runtime.server.waitForReviewRestartWaiterCountForTesting( + tokenID: token.id, + atLeast: 1 + ) + + await #expect(throws: CodexAppServerError.reviewRestartUnavailable(token.id)) { + try await runtime.server.restartPreparedReview( + token, + target: .baseBranch("release") + ) + } + + await resumeGate.open() + #expect(try await restart.value.turnID == "turn-restarted") + #expect(await runtime.transport.recordedRequests(method: "thread/rollback").count == 1) + #expect(await runtime.transport.recordedRequests(method: "review/start").count == 1) + } + + @Test func reviewStartSeedsDetachedTurnRoutingForTurnOnlyTerminalNotifications() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let reviewTask = Task { + try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-review", turnID: "turn-review") + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-review", + turnID: "turn-review", + item: .init(id: "message-early", type: "agentMessage", text: "Buffered") + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-review", status: "completed")) + ) + await gate.open() + let review = try await reviewTask.value + + var eventIterator = review.events.makeAsyncIterator() + var terminalResponse: CodexResponse? + while let event = try await eventIterator.next() { + if case .terminal(.completed(let response)) = event { + terminalResponse = response + } + } + if let terminalResponse { + #expect(terminalResponse.turnID == "turn-review") + #expect(terminalResponse.transcript.items.contains { $0.text == "Buffered" }) + } else { + Issue.record("Expected review.events to receive turn-only completion.") + } + + var progressIterator = review.progress.makeAsyncIterator() + let progress = try #require(try await progressIterator.next()) + if case .terminal(let outcome) = progress { + #expect(outcome.response.turnID == "turn-review") + } else { + Issue.record("Expected terminal review progress.") + } + #expect(try await progressIterator.next() == nil) + } + + @Test func normalTurnSeedsThreadRoutingForTurnOnlyTerminalNotifications() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + let eventTask = Task { () -> CodexThreadEvent? in + var iterator = thread.events.makeAsyncIterator() + while let event = try await iterator.next() { + if case .terminal = event { + return event + } + } + return nil + } + defer { + eventTask.cancel() + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + + _ = try await thread.streamResponse(to: "Run checks.") + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + + let event = try await withTimeout { + try await eventTask.value + } + if case .terminal(.completed(let response)) = event { + #expect(response.turnID == "turn-1") + } else { + Issue.record("Expected thread.events to receive turn-only completion.") + } + } + + @Test func reviewEventsAndLogsPreserveTurnDiagnosticsWithoutFabricatingTerminal() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Review.Start.Response( + turnID: "turn-review", + reviewThreadID: "thread-review" + ), + for: "review/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let review = try await thread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + let reviewThread = CodexThread( + id: "thread-review", + client: client, + router: router, + connectionLease: harness.lease + ) + let expectedDiagnostics = [ + CodexTurnDiagnostic( + error: .init( + message: "recoverable", + info: .serverOverloaded, + additionalDetails: "retry scheduled" + ), + willRetry: true + ), + CodexTurnDiagnostic( + error: .init(message: "not recoverable", info: .badRequest), + willRetry: false + ), + ] + let diagnosticEvents = Task { () throws -> [CodexTurnDiagnostic] in + var diagnostics: [CodexTurnDiagnostic] = [] + for try await event in review.events { + if case .diagnostic(let diagnostic, turnID: "turn-review") = event { + diagnostics.append(diagnostic) + if diagnostics.count == expectedDiagnostics.count { + return diagnostics + } + } + } + return diagnostics + } + let turnDiagnosticLogs = Task { () throws -> [CodexThreadLogEntry] in + var entries: [CodexThreadLogEntry] = [] + for try await entry in review.logEntries { + if case .diagnostic = entry { + entries.append(entry) + if entries.count == expectedDiagnostics.count { + return entries + } + } + } + return entries + } + let threadDiagnosticLogs = Task { () throws -> [CodexThreadLogEntry] in + var entries: [CodexThreadLogEntry] = [] + for try await entry in reviewThread.logEntries { + if case .diagnostic = entry { + entries.append(entry) + if entries.count == expectedDiagnostics.count { + return entries + } + } + } + return entries + } + defer { + diagnosticEvents.cancel() + turnDiagnosticLogs.cancel() + threadDiagnosticLogs.cancel() + } + #expect(await eventually { + let turnSubscriberCount = await harness.turnReplayStore.subscriberCountForTesting( + turnID: "turn-review" + ) + return turnSubscriberCount == 2 + && router.threadSubscriberCountForTesting(for: "thread-review") == 1 + }) + let emitter = CodexAppServerTestNotificationEmitter(transport: transport) + try await emitter.emitError( + threadID: "thread-review", + turnID: "turn-review", + error: expectedDiagnostics[0].error, + willRetry: expectedDiagnostics[0].willRetry + ) + try await emitter.emitError( + threadID: "thread-review", + turnID: "turn-review", + error: expectedDiagnostics[1].error, + willRetry: expectedDiagnostics[1].willRetry + ) + + #expect(try await withTimeout { try await diagnosticEvents.value } == expectedDiagnostics) + let turnLogEntries = try await withTimeout { try await turnDiagnosticLogs.value } + let threadLogEntries = try await withTimeout { try await threadDiagnosticLogs.value } + for logEntries in [turnLogEntries, threadLogEntries] { + #expect(logEntries.map(\.diagnostic) == expectedDiagnostics.map(Optional.some)) + #expect(logEntries.map(\.turnID) == ["turn-review", "turn-review"]) + #expect(logEntries.map(\.phase) == [.diagnostic, .diagnostic]) + #expect(Set(logEntries.map(\.id)).count == expectedDiagnostics.count) + } + #expect(try await review.response.turn.state.cachedOutcome() == nil) + + var progress = review.progress.makeAsyncIterator() + guard case .running(let transcript, let usage) = try await progress.next() else { + Issue.record("A nonterminal diagnostic must preserve running progress.") + return + } + #expect(transcript.items.isEmpty) + #expect(usage == nil) + await harness.close() + } + + @Test func promptPartsEncodeToAppServerInputItems() { + let prompt = CodexPrompt(parts: [ + .text("Describe these files."), + .imageURL(URL(string: "https://example.test/diagram.png")!), + .localImage(URL(fileURLWithPath: "/tmp/screenshot.png")), + .skill(name: "checks", path: URL(fileURLWithPath: "/tmp/skills/checks")), + .mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")), + .mention(name: "app", path: URL(string: "app://demo-app")!), + .mention(name: "plugin", path: URL(string: "plugin://sample@test")!), + ]) + + #expect( + prompt.appServerInput == [ + .text("Describe these files."), + .image(url: "https://example.test/diagram.png"), + .localImage(path: "/tmp/screenshot.png"), + .skill(name: "checks", path: "/tmp/skills/checks"), + .mention(name: "repo", path: "/tmp/repo"), + .mention(name: "app", path: "app://demo-app"), + .mention(name: "plugin", path: "plugin://sample@test"), + ]) + } + + @Test func threadStatusNormalizesAppServerLiveStates() { + #expect(CodexThreadStatus(rawValue: "active") == .active(activeFlags: [])) + #expect(CodexThreadStatus(rawValue: "idle") == .idle) + #expect(CodexThreadStatus(rawValue: "notLoaded") == .notLoaded) + #expect(CodexThreadStatus(rawValue: "systemError") == .systemError) + } + + @Test func clientRetriesOverloadedRequestsThenSucceeds() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32001, message: "server busy", for: "ping") + try await transport.enqueue(EmptyResponse(), for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + overloadRetryDelay: { $0 == 0 ? .zero : nil }, + retrySleep: { _ in } + ) + let client = harness.client + + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + + #expect(await transport.recordedRequests(method: "ping").count == 2) + await harness.close() + } + + @Test func requestFailurePreservesCorrelationAndRawServerData() async throws { + let transport = CodexAppServerTestTransport() + let rawData = Data( + #"{"reason":"busy","message":"turn rejected","codexErrorInfo":"serverOverloaded"}"#.utf8 + ) + await transport.enqueueFailure( + .responseError(.init( + code: -32_000, + message: "rejected", + data: rawData + )), + for: "ping" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + do { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected request failure.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind + else { + Issue.record("Expected typed server request failure, got \(error).") + return + } + #expect(failure.requestID == 1) + #expect(failure.method == "ping") + #expect(failure.purpose == .operation("ping")) + let serverData = try #require(serverError.data) + let serverDataObject = try #require( + JSONSerialization.jsonObject(with: serverData) as? [String: String] + ) + #expect(serverDataObject == [ + "reason": "busy", + "message": "turn rejected", + "codexErrorInfo": "serverOverloaded", + ]) + #expect(serverError.turnError == .init(message: "turn rejected", info: .serverOverloaded)) + } + await harness.close() + } + + @Test func outboundWriterFailurePreservesRequestCorrelation() async throws { + let failure = CodexTransportFailure.io(errno: EPIPE, message: "broken pipe") + let transport = TestOutboundWriteFailureTransport(failure: failure) + let client = AppServerClient( + transport: transport, + connectionCloseAction: testConnectionCloseAction(for: transport) + ) + + do { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected an outbound write failure.") + } catch let error as CodexAppServerError { + #expect(error == .request(.init( + requestID: 1, + method: "ping", + purpose: .operation("ping"), + kind: .write(failure) + ))) + } + } + + @Test func requestInvalidResponsePreservesCorrelationAndRawBytes() async throws { + struct RequiredResponse: Decodable, Sendable { + var value: String + } + + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON(#"{"unexpected":true}"#, for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + do { + let _: RequiredResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: RequiredResponse.self + ) + Issue.record("Expected invalid response failure.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .invalidResponse(_, _, let rawData) = failure.kind + else { + Issue.record("Expected typed invalid response, got \(error).") + return + } + #expect(failure.requestID == 1) + #expect(failure.method == "ping") + #expect(failure.purpose == .operation("ping")) + #expect(rawData == Data(#"{"unexpected":true}"#.utf8)) + } + await harness.close() + } + + @Test func responseReconciliationFailureTerminatesWithoutRejectingTheAcceptedWrite() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let failure = CodexTransportFailure.contractViolation( + message: "Response turn does not match the live generation." + ) + let writeAcceptedCount = Mutex(0) + let responseRejectedCount = Mutex(0) + let responseAcceptedCount = Mutex(0) + + do { + let _: EmptyResponse = try await harness.client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + reconcileResponse: { _ in throw failure }, + onWriteAccepted: { writeAcceptedCount.withLock { $0 += 1 } }, + onResponseRejected: { responseRejectedCount.withLock { $0 += 1 } }, + onResponseAccepted: { responseAcceptedCount.withLock { $0 += 1 } } + ) + Issue.record("Expected response reconciliation to terminate the connection.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(.transportFailure(failure))) + } + + #expect(writeAcceptedCount.withLock { $0 } == 1) + #expect(responseRejectedCount.withLock { $0 } == 0) + #expect(responseAcceptedCount.withLock { $0 } == 0) + #expect( + await harness.supervisor.terminationForTesting() + == .transportFailure(failure) + ) + } + + @Test func requestCancellationIsNeverWrapped() async throws { + let transport = CodexAppServerTestTransport() + try await transport.handle(method: "ping") { _ in + throw CancellationError() + } + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + do { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected CancellationError.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + await harness.close() + } + + @Test func cancellationBeforeTransportAcceptsWriteHasNoWireEffect() async throws { + let transport = TestPreWriteSuspendingTransport( + response: try JSONEncoder().encode(EmptyResponse()) + ) + let client = AppServerClient( + transport: transport, + connectionCloseAction: testConnectionCloseAction(for: transport) + ) + let scope = AppServerAPI.RequestScope.thread("thread-1") + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + await transport.waitUntilSendEntered() + task.cancel() + await transport.allowWriteAcceptance() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(transport.wireRequestCount() == 0) + #expect(await client.requestLaneCountForTesting() == 0) + } + + @Test func transportClosureIsConnectionTerminationNotRequestFailure() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(.closed, for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + do { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected connection termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(.transportFailure(.closed))) + } + await harness.close() + } + + @Test func requestDeadlineUsesInjectedMonotonicClockAndKeepsCorrelation() async throws { + let transport = TestSuspendingTransport(response: try JSONEncoder().encode(EmptyResponse())) + let deadlineGate = CodexAppServerTestGate() + let deadlineReturned = TestSignal() + let client = AppServerClient( + transport: transport, + deadlines: .init(request: .seconds(5)), + deadlineClock: .init { duration in + #expect(duration == .seconds(5)) + await deadlineGate.waitIgnoringCancellation() + deadlineReturned.signal() + }, + connectionCloseAction: testConnectionCloseAction(for: transport) + ) + + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + purpose: .operation("ping"), + deadline: .seconds(5) + ) + } + await transport.waitUntilStarted() + await deadlineGate.open() + await deadlineReturned.wait() + await transport.waitUntilCancelled() + do { + try await task.value + Issue.record("Expected request deadline.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .deadlineExceeded(let duration) = failure.kind + else { + Issue.record("Expected request deadline, got \(error).") + return + } + #expect(failure.requestID == 1) + #expect(failure.method == "ping") + #expect(failure.purpose == .operation("ping")) + #expect(duration == .seconds(5)) + } + } + + @Test func requestDeadlineBoundsOverloadBackoffAndEntireRetryLoop() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32_001, message: "busy", for: "ping") + let backoffStarted = TestSignal() + let backoffCancelled = TestSignal() + let backoffWaiter = TestCancellationWaiter { + backoffCancelled.signal() + } + let deadlineGate = CodexAppServerTestGate() + let deadlineReturned = TestSignal() + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { duration in + #expect(duration == .seconds(5)) + await deadlineGate.waitIgnoringCancellation() + deadlineReturned.signal() + }, + overloadRetryDelay: { attempt in + #expect(attempt == 0) + return .seconds(30) + }, + retrySleep: { duration in + #expect(duration == .seconds(30)) + backoffStarted.signal() + try await backoffWaiter.wait() + } + ) + let client = harness.client + var connectionIterator = await harness.server.connectionEvents().makeAsyncIterator() + + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + purpose: .operation("ping"), + deadline: .seconds(5) + ) + } + await backoffStarted.wait() + #expect(await connectionIterator.next() == .retrying(.init( + requestID: 1, + method: "ping", + attempt: 1, + delay: .seconds(30), + serverError: .init(code: -32_001, message: "busy") + ))) + await deadlineGate.open() + await deadlineReturned.wait() + await backoffCancelled.wait() + + do { + try await task.value + Issue.record("Expected request deadline during overload backoff.") + } catch let error as CodexAppServerError { + #expect(error == .request(.init( + requestID: 1, + method: "ping", + purpose: .operation("ping"), + kind: .deadlineExceeded(.seconds(5)) + ))) + } + #expect(await transport.recordedRequests(method: "ping").count == 1) + await harness.close() + } + + @Test func overloadBackoffCancellationIsNeverWrapped() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32_001, message: "busy", for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + overloadRetryDelay: { _ in .seconds(30) }, + retrySleep: { _ in throw CancellationError() } + ) + let client = harness.client + + do { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + Issue.record("Expected CancellationError.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + await harness.close() + } + + @Test func callerCancellationStopsShieldedOverloadBackoff() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure(code: -32_001, message: "busy", for: "ping") + let backoffStarted = TestSignal() + let backoffCancelled = TestSignal() + let backoffWaiter = TestCancellationWaiter { + backoffCancelled.signal() + } + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + overloadRetryDelay: { _ in .seconds(30) }, + retrySleep: { _ in + backoffStarted.signal() + try await backoffWaiter.wait() + } + ) + let client = harness.client + let task = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self + ) + } + await backoffStarted.wait() + task.cancel() + await backoffCancelled.wait() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(await transport.recordedRequests(method: "ping").count == 1) + await harness.close() + } + + @Test func handshakeDeadlineTakesPrecedenceOverGenericRequestDeadline() async throws { + let transport = TestSuspendingTransport(response: try JSONEncoder().encode( + AppServerAPI.Initialize.Response(codexHome: "/tmp/codex") + )) + let deadlineGate = CodexAppServerTestGate() + let deadlineReturned = TestSignal() + let client = AppServerClient( + transport: transport, + deadlines: .init(handshake: .seconds(7), request: .seconds(2)), + deadlineClock: .init { duration in + #expect(duration == .seconds(7)) + await deadlineGate.waitIgnoringCancellation() + deadlineReturned.signal() + }, + connectionCloseAction: testConnectionCloseAction(for: transport) + ) + + let task = Task { try await client.initialize() } + await transport.waitUntilStarted() + await deadlineGate.open() + await deadlineReturned.wait() + await transport.waitUntilCancelled() + do { + _ = try await task.value + Issue.record("Expected handshake deadline.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .deadlineExceeded(let duration) = failure.kind + else { + Issue.record("Expected handshake deadline, got \(error).") + return + } + #expect(failure.requestID == 1) + #expect(failure.method == "initialize") + #expect(failure.purpose == .handshake) + #expect(duration == .seconds(7)) + } + } + + @Test func turnDeadlineKeepsHandleAndDoesNotInterruptServerTurn() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueTurnStart(turnID: "turn-deadline", status: "inProgress") + let deadlineGate = CodexAppServerTestGate() + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { duration in + #expect(duration == .seconds(9)) + await deadlineGate.waitIgnoringCancellation() + } + ) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + let stream = try await thread.streamResponse(to: "Wait for terminal.") + + let task = Task { try await stream.collect(timeout: .seconds(9)) } + await deadlineGate.open() + do { + _ = try await task.value + Issue.record("Expected turn deadline.") + } catch let error as CodexAppServerError { + #expect(error == .turnDeadlineExceeded(turnID: "turn-deadline", duration: .seconds(9))) + } + #expect(await transport.recordedRequests(method: "turn/interrupt").isEmpty) + + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-deadline", status: "completed")) + ) + guard case .completed(let response) = try await stream.collect() else { + Issue.record("Expected terminal replay after deadline.") + return + } + #expect(response.turnID == "turn-deadline") + } + + @Test func responseCollectionCancellationIsLocalAndTerminalRemainsReplayable() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueTurnStart(turnID: "turn-local-cancel", status: "inProgress") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + let stream = try await thread.streamResponse(to: "Keep running.") + + let task = Task { try await stream.collect() } + #expect(await eventually { + await harness.turnReplayStore.subscriberCountForTesting( + turnID: "turn-local-cancel" + ) == 1 + }) + task.cancel() + do { + _ = try await task.value + Issue.record("Expected local collection cancellation.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + #expect(await transport.recordedRequests(method: "turn/interrupt").isEmpty) + + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-local-cancel", status: "completed")) + ) + guard case .completed(let response) = try await stream.collect() else { + Issue.record("Expected replayed terminal outcome.") + return + } + #expect(response.turnID == "turn-local-cancel") + } + + @Test func scopedRequestCancelledWhileQueuedDoesNotSend() async throws { + let transport = CodexAppServerTestTransport() + let gate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation(method: "turn/start", gate: gate) + try await transport.enqueueTurnStart(turnID: "turn-1", status: "running") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + + let first = Task { + try await client.send(AppServerAPI.Turn.Start.Request( + params: .init(threadID: "thread-1", input: [.text("first")]) + )) + } + await transport.waitForRequest(method: "turn/start") + + let second = Task { + try await client.send(AppServerAPI.Turn.Start.Request( + params: .init(threadID: "thread-1", input: [.text("second")]) + )) + } + try await client.waitForQueuedRequestCountForTesting( + scope: .thread("thread-1"), + atLeast: 1 + ) + second.cancel() + + do { + _ = try await second.value + Issue.record("Expected the queued scoped request to throw CancellationError.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + #expect(await client.queuedRequestCountForTesting(scope: .thread("thread-1")) == 0) + #expect(await client.requestLaneCountForTesting() == 1) + + await gate.open() + _ = try await first.value + #expect(await transport.recordedRequests(method: "turn/start").count == 1) + #expect(await client.requestLaneCountForTesting() == 0) + await harness.close() + } + + @Test func postWriteCancellationKeepsLaneUntilCorrelatedResponse() async throws { + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation(method: "ping", gate: responseGate) + try await transport.enqueueEmpty(for: "ping") + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let scope = AppServerAPI.RequestScope.thread("thread-1") + + let first = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + await transport.waitForRequest(method: "ping") + first.cancel() + + let second = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + try await client.waitForQueuedRequestCountForTesting(scope: scope, atLeast: 1) + + #expect(await transport.recordedRequests(method: "ping").count == 1) + await responseGate.open() + + await #expect(throws: CancellationError.self) { + try await first.value + } + try await second.value + #expect(await transport.recordedRequests(method: "ping").count == 2) + #expect(await client.requestLaneCountForTesting() == 0) + await harness.close() + } + + @Test func postWriteCancellationKeepsLaneThroughRequiredCleanup() async throws { + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + let cleanupStarted = TestSignal() + let cleanupGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation(method: "ping", gate: responseGate) + try await transport.enqueueEmpty(for: "ping") + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let scope = AppServerAPI.RequestScope.thread("thread-1") + + let first = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope, + onPostWriteCancellation: { _ in + cleanupStarted.signal() + await cleanupGate.waitIgnoringCancellation() + } + ) + } + await transport.waitForRequest(method: "ping") + first.cancel() + await responseGate.open() + await cleanupStarted.wait() + + let second = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + try await client.waitForQueuedRequestCountForTesting(scope: scope, atLeast: 1) + + #expect(await transport.recordedRequests(method: "ping").count == 1) + await cleanupGate.open() + await #expect(throws: CancellationError.self) { + try await first.value + } + try await second.value + #expect(await transport.recordedRequests(method: "ping").count == 2) + #expect(await client.requestLaneCountForTesting() == 0) + await harness.close() + } + + @Test func cleanupChildWithStaleLaneTokenQueuesBehindCurrentOwner() async throws { + let transport = CodexAppServerTestTransport() + let firstResponseGate = CodexAppServerTestGate() + let blockerResponseGate = CodexAppServerTestGate() + let staleChildGate = CodexAppServerTestGate() + let staleChildTask = Mutex?>(nil) + await transport.holdNextIgnoringCancellation(method: "ping", gate: firstResponseGate) + await transport.holdNextIgnoringCancellation(method: "ping", gate: blockerResponseGate) + try await transport.enqueueEmpty(for: "ping") + try await transport.enqueueEmpty(for: "ping") + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let scope = AppServerAPI.RequestScope.thread("thread-1") + + let first = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope, + onPostWriteCancellation: { _ in + let task = Task { + await staleChildGate.waitIgnoringCancellation() + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + staleChildTask.withLock { $0 = task } + } + ) + } + await transport.waitForRequest(method: "ping") + first.cancel() + await firstResponseGate.open() + await #expect(throws: CancellationError.self) { + try await first.value + } + + let blocker = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + await transport.waitForRequest(method: "ping", count: 2) + let child = try #require(staleChildTask.withLock { $0 }) + await staleChildGate.open() + try await withTimeout { + try await client.waitForQueuedRequestCountForTesting(scope: scope, atLeast: 1) + } + + #expect(await transport.recordedRequests(method: "ping").count == 2) + await blockerResponseGate.open() + try await blocker.value + try await child.value + #expect(await transport.recordedRequests(method: "ping").count == 3) + #expect(await client.requestLaneCountForTesting() == 0) + await harness.close() + } + + @Test func postWriteDeadlineClosesConnectionBeforeReleasingLane() async throws { + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + let deadlineGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation(method: "ping", gate: responseGate) + try await transport.enqueueEmpty(for: "ping") + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { duration in + #expect(duration == .seconds(5)) + await deadlineGate.waitIgnoringCancellation() + } + ) + let client = harness.client + let scope = AppServerAPI.RequestScope.thread("thread-1") + + let first = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope, + deadline: .seconds(5) + ) + } + await transport.waitForRequest(method: "ping") + + let second = Task { + let _: EmptyResponse = try await client.send( + method: "ping", + params: EmptyResponse(), + responseType: EmptyResponse.self, + scope: scope + ) + } + try await client.waitForQueuedRequestCountForTesting(scope: scope, atLeast: 1) + + await deadlineGate.open() + do { + try await first.value + Issue.record("Expected request deadline.") + } catch let error as CodexAppServerError { + #expect(error == .request(.init( + requestID: 1, + method: "ping", + purpose: .operation("ping"), + kind: .deadlineExceeded(.seconds(5)) + ))) + } + await #expect(throws: CodexAppServerError.self) { + try await second.value + } + + #expect(await transport.recordedRequests(method: "ping").count == 1) + #expect(await transport.isClosedForTesting()) + #expect(await client.requestLaneCountForTesting() == 0) + } + + @Test func turnResultReplaysEarlyDomainEventsAndRoutesUnknownToConnection() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let connectionEvents = await harness.server.connectionEvents() + let connectionEventTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.waitForNotificationStreamCount(1) + let state = TurnGenerationHandleState(connectionLease: harness.lease) + let pending = await harness.turnReplayStore.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let futureParams = TurnIDParams(turnID: "turn-1") + try await transport.emitServerNotification( + method: "future/notification", + params: futureParams + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", itemID: "message-1", delta: "Done") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + await harness.turnReplayStore.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let turn = CodexTurn( + id: "turn-1", + threadID: "thread-1", + client: client, + router: router, + turnReplayStore: harness.turnReplayStore, + state: state + ) + + let events = try await collect(turn.events) + #expect(events.contains { + if case .unknown = $0 { true } else { false } + } == false) + guard case .unknown(let raw) = await connectionEventTask.value else { + Issue.record("Expected the future notification on the connection stream.") + return + } + #expect(raw.method == "future/notification") + #expect(raw.params == (try JSONEncoder().encode(futureParams))) + #expect(raw.threadID == nil) + #expect(raw.turnID == "turn-1") + let result = try await turn.result() + #expect(result.response.turnID == "turn-1") + #expect(result == .completed(result.response)) + #expect(result.response.transcript.finalAnswer == "Done") + } + + @Test func threadEventStreamCancellationRemovesRouterSubscriber() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let consumer = Task { + var iterator = thread.events.makeAsyncIterator() + _ = try await iterator.next() + } + + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + consumer.cancel() + try await consumer.value + #expect(router.threadSubscriberCountForTesting(for: "thread-1") == 0) + } + + @Test func directThreadEventStreamCancellationRemovesRouterSubscriber() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let router = harness.router + let stream = router.events(for: CodexThreadID(rawValue: "thread-1")) + #expect(router.threadSubscriberCountForTesting(for: "thread-1") == 1) + stream.cancel() + #expect(router.threadSubscriberCountForTesting(for: "thread-1") == 0) + } + + @Test func liveThreadEventStreamFinishesWhenHistoryIsAlreadyTerminal() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let replayStream = router.events(for: CodexThreadID(rawValue: "thread-1")) + let replayEvents = try await withTimeout { + try await collect(replayStream) + } + #expect(replayEvents.contains { event in + if case .closed = event { + return true + } + return false + }) + + #expect(router.threadSubscriberCountForTesting(for: "thread-1") == 0) + } + + @Test func threadEventStreamsReplayOnlyCurrentGenerationAfterNewGenerationStarts() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let firstGeneration = try await withTimeout { + try await collect(router.events(for: CodexThreadID(rawValue: "thread-1"))) + } + #expect(firstGeneration.contains { event in + if case .closed = event { + return true + } + return false + }) + + router.resetThreadEventGeneration("thread-1") + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-2", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-2", + itemID: "message-1", + delta: "Current" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let currentGeneration = try await withTimeout { + try await collect(router.events(for: "thread-1")) + } + #expect(currentGeneration.containsThreadText("Current", turnID: "turn-2")) + #expect(currentGeneration.last == .closed) + } + + @Test func threadGenerationIncludingTurnStartsAfterPriorTerminalTurn() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-review", turnID: "turn-old") + ) + try await emitItemStarted( + on: transport, + threadID: "thread-review", + turnID: "turn-old", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review", + turnID: "turn-old", + itemID: "message-1", + delta: "Old" + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-review", turn: .init(id: "turn-old", status: "completed")) + ) + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-review", turnID: "turn-current") + ) + try await emitItemStarted( + on: transport, + threadID: "thread-review", + turnID: "turn-current", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review", + turnID: "turn-current", + itemID: "message-1", + delta: "Current" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + + let provisionalGeneration = try await withTimeout { + try await collect(router.events(for: "thread-review")) + } + #expect(provisionalGeneration.last == .closed) + await router.adoptThreadEventGeneration( + "thread-review", + including: "turn-current" + ) + let currentGeneration = try await withTimeout { + try await collect(router.events(for: "thread-review")) + } + + #expect(currentGeneration.contains { event in + if case .messageDelta(let delta, let turnID) = event { + return delta.text == "Old" || turnID == "turn-old" + } + return false + } == false) + #expect(currentGeneration.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "turn-current" + } + return false + }) + #expect(currentGeneration.containsThreadText("Current", turnID: "turn-current")) + #expect(currentGeneration.last == .closed) + } + + @Test func resumeThreadReidentifiesResponseSnapshotFromCanonicalEventsDuringResume() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let gate = CodexAppServerTestGate() + await runtime.transport.holdNext(method: "thread/resume", gate: gate) + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-resume-events", + turns: [ + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [ + .init( + id: "response-message", + kind: .agentMessage, + content: .message(.init( + id: "response-message", + role: .assistant, + text: "From resume response" + )) + ), + ] + ), + ] + )) + + let resumeTask = Task { + try await runtime.server.resumeThread("thread-resume-events") + } + await runtime.transport.waitForRequest(method: "thread/resume") + try await emitItemStarted( + on: runtime.transport, + threadID: "thread-resume-events", + turnID: "turn-resume-events", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-resume-events", + turnID: "turn-resume-events", + itemID: "message-1", + delta: "During resume" + ) + ) + await gate.open() + + let thread = try await resumeTask.value + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-resume-events") + ) + + let events = try await collect(thread.events) + #expect(events.containsThreadText("From resume response", turnID: "turn-resume-events")) + #expect(events.containsThreadText("During resume", turnID: "turn-resume-events")) + #expect(events.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "rollout-synthesized-turn" + } + return false + } == false) + } + + @Test func resumeThreadReidentifiesProvisionalResponseWhenCanonicalEventsArriveLater() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-resume-events", + turns: [ + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [ + .init( + id: "response-message", + kind: .agentMessage, + content: .message(.init( + id: "response-message", + role: .assistant, + text: "From resume response" + )) + ), + ] + ), + ] + )) + + let thread = try await runtime.server.resumeThread("thread-resume-events") + try await emitItemStarted( + on: runtime.transport, + threadID: "thread-resume-events", + turnID: "turn-resume-events", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-resume-events", + turnID: "turn-resume-events", + itemID: "message-1", + delta: "After resume" + ) + ) + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-resume-events") + ) + + let events = try await collect(thread.events) + #expect(events.containsThreadText("From resume response", turnID: "turn-resume-events")) + #expect(events.containsThreadText("After resume", turnID: "turn-resume-events")) + #expect(events.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "rollout-synthesized-turn" + } + return false + } == false) + } + + @Test func streamResponseBeginsNewThreadEventGeneration() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueTurnStart(turnID: "turn-2", status: "running") + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "turn/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "message-1", + delta: "Previous generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let previousEvents = try await withTimeout { + try await collect(thread.events) + } + #expect(previousEvents.last == .closed) + + let streamTask = Task { + try await thread.streamResponse(to: "Next turn.") + } + await transport.waitForRequest(method: "turn/start") + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-2", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + turnID: "turn-2", + itemID: "message-1", + delta: "During start" + ) + ) + await gate.open() + let responseStream = try await streamTask.value + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let events = try await withTimeout { + try await collect(thread.events) + } + #expect(events.count == 3) + #expect(events.containsThreadText("During start", turnID: "turn-2")) + #expect(events.containsThreadText("Previous generation", turnID: "turn-1") == false) + #expect(events.last == .closed) + withExtendedLifetime(responseStream) {} + } + + @Test func startReviewBeginsNewThreadEventGeneration() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-1", + status: .inProgress + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-previous", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-previous", + itemID: "message-1", + delta: "Previous generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let previousEvents = try await withTimeout { + try await collect(thread.events) + } + #expect(previousEvents.last == .closed) + + let reviewTask = Task { + try await thread.startReview(target: .baseBranch("main")) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-1", turnID: "turn-review-child") + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-review", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + turnID: "turn-review", + itemID: "message-1", + delta: "During review start" + ) + ) + await gate.open() + let review = try await reviewTask.value + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-1", turnID: "turn-review-child-late") + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-review", + item: .init(id: "review-output", type: "agentMessage", text: "No issues.") + ) + ) + let eventsTask = Task { + try await collect(thread.events) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let events = try await withTimeout { + try await eventsTask.value + } + #expect(events.containsThreadText("During review start", turnID: "turn-review")) + #expect(events.containsThreadText("Previous generation", turnID: "turn-previous") == false) + #expect(events.last == .closed) + withExtendedLifetime(review) {} + } + + @Test func detachedStartReviewBeginsReviewThreadEventGeneration() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + status: .inProgress + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-source", client: client, router: router, connectionLease: harness.lease) + let reviewThread = CodexThread(id: "thread-review", client: client, router: router, connectionLease: harness.lease) + + let oldSourceEventsTask = Task { + try await collect(thread.events) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-source") == 1 + }) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-source") + ) + let oldSourceEvents = try await withTimeout { + try await oldSourceEventsTask.value + } + #expect(oldSourceEvents.last == .closed) + + try await emitItemStarted( + on: transport, + threadID: "thread-review-previous", + turnID: "turn-previous", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review-previous", + turnID: "turn-previous", + itemID: "message-1", + delta: "Previous detached generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review-previous") + ) + let previousEvents = try await withTimeout { + try await collect(router.events(for: "thread-review-previous")) + } + #expect(previousEvents.last == .closed) + + let reviewTask = Task { + try await thread.startReview(target: .baseBranch("main"), delivery: .detached) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotificationJSON( + method: "thread/status/changed", + json: #"{"threadId":"thread-source","status":{"type":"active","activeFlags":["waitingOnApproval"]}}"# + ) + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-review", turnID: "turn-review-child") + ) + try await transport.emitServerNotificationJSON( + method: "thread/status/changed", + json: #"{"threadId":"thread-review","status":{"type":"active","activeFlags":[]}}"# + ) + try await emitItemStarted( + on: transport, + threadID: "thread-review", + turnID: "turn-review", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review", + turnID: "turn-review", + itemID: "message-1", + delta: "During detached review start" + ) + ) + await gate.open() + let review = try await reviewTask.value + withExtendedLifetime(review) {} + let sourceEventsTask = Task { + try await collect(thread.events) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-source") == 1 + }) + let eventsTask = Task { + try await collect(reviewThread.events) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-review") == 1 + }) + + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-review") + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-source") + ) + + let events = try await withTimeout { + try await eventsTask.value + } + #expect(events.contains(.statusChanged(.active(activeFlags: [])))) + #expect(events.containsThreadText("During detached review start", turnID: "turn-review")) + #expect(events.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "turn-review-child" + } + return false + } == false) + #expect( + events.containsThreadText("Previous detached generation", turnID: "turn-previous") + == false + ) + #expect(events.last == .closed) + let sourceEvents = try await withTimeout { + try await sourceEventsTask.value + } + #expect(sourceEvents.contains(.statusChanged(.active(activeFlags: [.waitingOnApproval])))) + #expect(sourceEvents.contains { event in + if case .snapshot(let snapshot) = event { + return snapshot.id == "turn-review" + } + return false + } == false) + #expect(sourceEvents.last == .closed) + } + + @Test func detachedReviewAmbiguityPreservesAnUnrelatedTurnStart() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + status: .inProgress + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let sourceThread = CodexThread( + id: "thread-source", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + let unrelatedThread = CodexThread( + id: "thread-unrelated", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + let unrelatedEvents = Task { + try await collect(unrelatedThread.events) + } + #expect(await eventually { + harness.router.threadSubscriberCountForTesting(for: "thread-unrelated") == 1 + }) + + let reviewTask = Task { + try await sourceThread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-unrelated", + turnID: "turn-unrelated" + ) + ) + #expect(await eventually { + await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 1, + candidateCount: 1, + drainCount: 0 + ) + }) + try await emitItemStarted( + on: transport, + threadID: "thread-unrelated", + turnID: "turn-unrelated", + item: .init(id: "message-unrelated", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-unrelated", + turnID: "turn-unrelated", + itemID: "message-unrelated", + delta: "Unrelated work" + ) + ) + await harness.router.seedTurn( + "turn-unrelated", + threadID: "thread-unrelated" + ) + #expect(await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 1, + candidateCount: 1, + drainCount: 0 + )) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-unrelated") + ) + await gate.open() + let review = try await reviewTask.value + withExtendedLifetime(review) {} + #expect(await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 0, + candidateCount: 0, + drainCount: 0 + )) + + let events = try await withTimeout { + try await unrelatedEvents.value + } + #expect(events.contains { event in + switch event { + case .turnStarted("turn-unrelated"): + true + case .snapshot(let snapshot): + snapshot.id == "turn-unrelated" + default: + false + } + }) + #expect(events.contains { event in + if case .itemStarted(_, let turnID) = event { + return turnID == "turn-unrelated" + } + return false + }) + #expect(events.containsThreadText("Unrelated work", turnID: "turn-unrelated")) + await harness.close() + } + + @Test func failedDetachedReviewReleasesRoutingCandidates() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_000, + message: "review start failed", + for: "review/start" + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let sourceThread = CodexThread( + id: "thread-source", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + + let reviewTask = Task { + try await sourceThread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-unrelated", + turnID: "turn-unrelated" + ) + ) + #expect(await eventually { + await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 1, + candidateCount: 1, + drainCount: 0 + ) + }) + + await gate.open() + do { + _ = try await reviewTask.value + Issue.record("Expected detached review start failure.") + } catch { + // Expected. + } + + #expect(await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 0, + candidateCount: 0, + drainCount: 0 + )) + await harness.close() + } + + @Test func detachedReviewRetryReleasesFirstAttemptCandidates() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_001, + message: "overloaded", + for: "review/start" + ) + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + status: .inProgress + ) + let firstResponseGate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: firstResponseGate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let sourceThread = CodexThread( + id: "thread-source", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + let unrelatedThread = CodexThread( + id: "thread-unrelated", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + let unrelatedEvents = Task { + try await collect(unrelatedThread.events) + } + #expect(await eventually { + harness.router.threadSubscriberCountForTesting(for: "thread-unrelated") == 1 + }) + + let reviewTask = Task { + try await sourceThread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + } + await transport.waitForRequest(method: "review/start") + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-unrelated", + turnID: "turn-unrelated" + ) + ) + try await emitItemStarted( + on: transport, + threadID: "thread-unrelated", + turnID: "turn-unrelated", + item: .init(id: "message-unrelated", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-unrelated", + turnID: "turn-unrelated", + itemID: "message-unrelated", + delta: "First attempt unrelated work" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-unrelated") + ) + #expect(await eventually { + (await harness.router.detachedReviewRoutingSnapshotForTesting()).candidateCount == 1 + }) + + await firstResponseGate.open() + #expect(await eventually { + (await harness.router.detachedReviewRoutingSnapshotForTesting()).candidateCount == 0 + }) + await transport.waitForRequest(method: "review/start", count: 2) + let review = try await reviewTask.value + withExtendedLifetime(review) {} + + #expect(await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 0, + candidateCount: 0, + drainCount: 0 + )) + let events = try await withTimeout { + try await unrelatedEvents.value + } + #expect(events.contains { event in + switch event { + case .turnStarted("turn-unrelated"): + true + case .snapshot(let snapshot): + snapshot.id == "turn-unrelated" + default: + false + } + }) + #expect(events.containsThreadText( + "First attempt unrelated work", + turnID: "turn-unrelated" + )) + #expect(events.last == .closed) + await harness.close() + } + + @Test func connectionTerminationCanInterleaveWithDetachedReviewDrain() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + status: .inProgress + ) + let responseGate = CodexAppServerTestGate() + let drainGate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: responseGate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await harness.router.setNotificationDrainPauseForTesting { + await drainGate.waitIgnoringCancellation() + } + let sourceThread = CodexThread( + id: "thread-source", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + let reviewTask = Task { + try await sourceThread.startReview( + target: .baseBranch("main"), + delivery: .detached + ) + } + await transport.waitForRequest(method: "review/start") + try await emitItemStarted( + on: transport, + threadID: "thread-review", + turnID: "turn-review", + item: .init(id: "message-review", type: "agentMessage", text: "") + ) + for index in 0..<256 { + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review", + turnID: "turn-review", + itemID: "message-review", + delta: "\(index)" + ) + ) + } + + await responseGate.open() + await drainGate.waitUntilBlocked() + let closeTask = Task { + await harness.close() + } + await closeTask.value + await drainGate.open() + do { + let review = try await reviewTask.value + withExtendedLifetime(review) {} + Issue.record("Expected connection termination, got a review session.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(.closedByCaller)) + } catch { + Issue.record("Expected connection termination, got \(error).") + } + #expect(await eventually { + await harness.router.detachedReviewRoutingSnapshotForTesting() == .init( + attemptCount: 0, + candidateCount: 0, + drainCount: 0 + ) + }) + } + + @Test func compactBeginsNewThreadEventGeneration() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueEmpty(for: "thread/compact/start") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-previous", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-previous", + itemID: "message-1", + delta: "Previous generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let previousEvents = try await withTimeout { + try await collect(thread.events) + } + #expect(previousEvents.last == .closed) + + try await thread.compact() + let eventsTask = Task { + try await collect(thread.events) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-compact", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-compact", + itemID: "message-1", + delta: "Current compact generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let events = try await withTimeout { + try await eventsTask.value + } + #expect(events.containsThreadText("Current compact generation", turnID: "turn-compact")) + #expect(events.containsThreadText("Previous generation", turnID: "turn-previous") == false) + #expect(events.last == .closed) + } + + @Test func failedReviewStartDoesNotAdvanceThreadEventGeneration() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_000, + message: "review start failed", + for: "review/start" + ) + let gate = CodexAppServerTestGate() + await transport.holdNext(method: "review/start", gate: gate) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-previous", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-previous", + itemID: "message-1", + delta: "Previous generation" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let previousEvents = try await withTimeout { + try await collect(thread.events) + } + #expect(previousEvents.last == .closed) + + let reviewTask = Task { + try await thread.startReview(target: .baseBranch("main")) + } + await transport.waitForRequest(method: "review/start") + await gate.open() + do { + _ = try await reviewTask.value + Issue.record("Expected review start failure.") + } catch { + // Expected failure; the existing generation must remain replayable. + } + + let events = try await withTimeout { + try await collect(thread.events) + } + #expect(events.containsThreadText("Previous generation", turnID: "turn-previous")) + #expect(events.containsThreadText("Failed review start", turnID: "turn-failed-review") == false) + #expect(events.last == .closed) + } + + @Test func failedResumeDoesNotAdvanceThreadEventGeneration() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-resume-failure")) + let thread = try await runtime.server.resumeThread("thread-resume-failure") + let priorEvents = thread.events + var priorIterator = priorEvents.makeAsyncIterator() + try await emitItemStarted( + on: runtime.transport, + threadID: "thread-resume-failure", + turnID: "turn-resume-failure", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-resume-failure", + turnID: "turn-resume-failure", + itemID: "message-1", + delta: "Before failed resume" + ) + ) + var routedPriorGeneration = false + while let event = try await priorIterator.next() { + if [event].containsThreadText( + "Before failed resume", + turnID: "turn-resume-failure" + ) { + routedPriorGeneration = true + break + } + } + #expect(routedPriorGeneration) + priorEvents.cancel() + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "resume failed", + for: "thread/resume" + ) + do { + _ = try await runtime.server.resumeThread("thread-resume-failure") + Issue.record("Expected resume failure.") + } catch { + // Expected failure; the existing generation must remain replayable. + } + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-resume-failure") + ) + + let events = try await collect(thread.events) + #expect(events.containsThreadText("Before failed resume", turnID: "turn-resume-failure")) + } + + @Test func threadStreamsReplayMessagesTranscriptLogsAndUsage() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "message-1", + type: "agentMessage", + text: "Interim", + phase: "commentary" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "message-2", + type: "agentMessage", + text: "Final", + phase: "final_answer" + ) + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "passed", + status: "completed" + ) + ) + ) + try await transport.emitServerNotification( + method: "thread/tokenUsage/updated", + params: TokenUsageParams( + threadID: "thread-1", + turnID: "turn-1", + tokenUsage: .init( + total: .init(inputTokens: 1, outputTokens: 2, totalTokens: 3) + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let messages = try await collect(thread.messages) + #expect(messages.map(\.text) == ["Interim", "Final"]) + #expect(messages.last?.phase == .finalAnswer) + + let transcripts = try await collect(thread.transcriptUpdates) + #expect(transcripts.last?.finalAnswer == "Final") + #expect(transcripts.last?.items.count == 3) + + let logs = try await collect(thread.logEntries) + #expect(logs.contains { $0.item?.kind == .commandExecution }) + #expect(logs.contains { $0.item?.text == "Final" }) + + let events = try await collect(thread.events) + #expect( + events.contains { + if case .terminal(let outcome) = $0 { + outcome.response.turnID == "turn-1" + && outcome.response.usage?.totalTokens == 3 + } else { + false + } + }) + } + + @Test func threadItemDecodeReadsTextObjectContentFragments() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "message-1", + type: "userMessage", + contentItems: [ + .init(text: "hello"), + .init(text: "world"), + ] + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + let transcripts = try await collect(thread.transcriptUpdates) + + #expect(transcripts.last?.items.first?.text == "hello\nworld") + } + + @Test func threadTranscriptSupersedesPriorTerminalGeneration() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + let transcriptsTask = Task { + try await collect(thread.transcriptUpdates) + } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 1 + }) + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-1", turnID: "turn-1") + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", itemID: "message-1", delta: "First") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-1", turnID: "turn-2") + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-2", + item: .init(id: "message-2", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-2", itemID: "message-2", delta: "Second") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-2", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let transcripts = try await transcriptsTask.value + + #expect(transcripts.last?.items.compactMap(\.text) == ["Second"]) + } + + @Test func responseStreamYieldsSnapshotsAndCollectsFinalResponse() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let stream = try await thread.streamResponse { + "Summarize this." + CodexPrompt.Part.mention(name: "repo", path: URL(fileURLWithPath: "/tmp/repo")) + } + var iterator = stream.makeAsyncIterator() + let started = try await iterator.next() + #expect(started?.turnID == "turn-1") + #expect(started?.content == nil) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "", phase: "final_answer") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", itemID: "message-1", delta: "Final") + ) + var updated = try await iterator.next() + while let snapshot = updated, snapshot.content != "Final" { + updated = try await iterator.next() + } + #expect(updated?.turnID == "turn-1") + #expect(updated?.content == "Final") + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + + let response = try await stream.collect() + #expect(response.response.turnID == "turn-1") + #expect(response.response.transcript.finalAnswer == "Final") + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, from: request.params) + #expect( + params.input == [ + .text("Summarize this."), + .mention(name: "repo", path: "/tmp/repo"), + ]) + } + + @Test func responseStreamDoesNotAppendCompleteAgentMessageAsDelta() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let stream = try await thread.streamResponse(to: "Summarize this.") + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "", phase: "final_answer") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", itemID: "message-1", delta: "Final") + ) + try await transport.emitServerNotification( + method: "agent/message", + params: AgentMessageParams(turnID: "turn-1", itemID: "message-1", message: "Final") + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + + let response = try await stream.collect() + + #expect(response.response.transcript.finalAnswer == "Final") + #expect(response.response.transcript.items.first?.text == "Final") + } + + @Test func responseStreamCollectsTranscriptFromCompletedTurnItems() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let stream = try await thread.streamResponse(to: "Summarize this.") + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-1", + status: "completed", + items: [ + .object([ + "id": .string("message-1"), + "type": .string("agentMessage"), + "text": .string("Final from payload"), + "phase": .string("final_answer"), + ]), + ] + )) + ) + + let response = try await stream.collect() + + #expect(response.response.transcript.finalAnswer == "Final from payload") + #expect(response.response.transcript.items.first?.text == "Final from payload") + } + + @Test func responseStreamSnapshotsIncludeIncrementalTokenUsage() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let stream = try await thread.streamResponse(to: "Summarize usage.") + var iterator = stream.makeAsyncIterator() + let initial = try await iterator.next() + #expect(initial?.turnID == "turn-1") + #expect(initial?.usage == nil) + try await transport.emitServerNotification( + method: "thread/tokenUsage/updated", + params: TokenUsageParams( + threadID: "thread-1", + turnID: "turn-1", + tokenUsage: .init( + total: .init(inputTokens: 5, outputTokens: 8, totalTokens: 13) + ) + ) + ) + + let snapshot = try await iterator.next() + + #expect(snapshot?.turnID == "turn-1") + #expect(snapshot?.usage?.inputTokens == 5) + #expect(snapshot?.usage?.outputTokens == 8) + #expect(snapshot?.usage?.totalTokens == 13) + #expect(snapshot?.response == nil) + } + + @Test func responseStreamFailureCarriesPartialResponse() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let stream = try await thread.streamResponse(to: "Try this.") + var iterator = stream.makeAsyncIterator() + let started = try await iterator.next() + #expect(started?.content == nil) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "", phase: "final_answer") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams(turnID: "turn-1", itemID: "message-1", delta: "Partial") + ) + var partial = try await iterator.next() + while let snapshot = partial, snapshot.content != "Partial" { + partial = try await iterator.next() + } + #expect(partial?.content == "Partial") + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init( + id: "turn-1", + status: "failed", + error: .init(message: "Tool failed."), + startedAt: 1_700_000_000, + completedAt: 1_700_000_001, + durationMS: 1_000 + )) + ) + + var terminal = try await iterator.next() + while let snapshot = terminal, snapshot.response == nil { + terminal = try await iterator.next() + } + #expect(terminal?.response?.transcript.responseText == "Partial") + #expect(terminal?.response?.startedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(terminal?.response?.duration == .milliseconds(1_000)) + #expect(try await iterator.next() == nil) + + let outcome = try await stream.collect() + if case .failed(let failedTurn) = outcome { + #expect(failedTurn.error.message == "Tool failed.") + #expect(failedTurn.response.transcript.responseText == "Partial") + } else { + Issue.record("Expected failed turn outcome.") + } + } + + @Test func responseStreamSerializesReasoningOptions() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + _ = try await thread.streamResponse( + to: "Explain the patch.", + options: .init( + effort: .high, + summary: .detailed, + outputSchema: .object([ + "type": .string("object"), + "properties": .object(["summary": .object(["type": .string("string")])]), + ]), + personality: .pragmatic, + clientUserMessageID: "client-message-1" + ) + ) + + let request = try #require(await transport.recordedRequests().first) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Start.Params.self, + from: request.params + ) + #expect(params.effort == "high") + #expect(params.summary == "detailed") + #expect(params.outputSchema == .object([ + "type": .string("object"), + "properties": .object(["summary": .object(["type": .string("string")])]), + ])) + #expect(params.personality == "pragmatic") + #expect(params.clientUserMessageID == "client-message-1") + } + + @Test func responseStreamSerializesSandboxPolicyWithAppServerSchema() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + _ = try await thread.streamResponse( + to: "Explain the patch.", + options: .init(sandbox: .workspaceWrite) + ) + + let request = try #require(await transport.recordedRequests().first) + let params = try #require( + JSONSerialization.jsonObject(with: request.params) as? [String: Any] + ) + let sandboxPolicy = try #require(params["sandboxPolicy"] as? [String: Any]) + #expect(sandboxPolicy["type"] as? String == "workspaceWrite") + #expect((sandboxPolicy["writableRoots"] as? [Any])?.isEmpty == true) + #expect(sandboxPolicy["networkAccess"] as? Bool == false) + #expect(sandboxPolicy["excludeTmpdirEnvVar"] as? Bool == false) + #expect(sandboxPolicy["excludeSlashTmp"] as? Bool == false) + #expect(sandboxPolicy.keys.contains("writable_roots") == false) + #expect(sandboxPolicy.keys.contains("network_access") == false) + } + + @Test func responseStreamSerializesApprovalPolicyWithAppServerSchema() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + _ = try await thread.streamResponse( + to: "Explain the patch.", + options: .init(approvalMode: .autoReview) + ) + + let request = try #require(await transport.recordedRequests().first) + let params = try #require( + JSONSerialization.jsonObject(with: request.params) as? [String: Any] + ) + #expect(params["approvalPolicy"] as? String == "on-request") + #expect(params["approvalPolicy"] as? String != "onRequest") + } + + @Test func messageDeltaLogEntriesUseUniqueEntryIDs() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + let logs = thread.logEntries + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "message-1", + delta: "First" + ) + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "message-1", + delta: "Second" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let collectedLogs = try await collect(logs) + let deltas = collectedLogs.filter { $0.phase == .delta } + #expect(deltas.map(\.id) == ["message-1:0", "message-1:1"]) + #expect(deltas.compactMap(\.messageDelta).map(\.text) == ["First", "Second"]) + } + + @Test func lateThreadLogEntriesReplayOnlyTheCurrentGeneration() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "message-1", + delta: "First" + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-2", + item: .init(id: "message-1", type: "agentMessage", text: "") + ) + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-1", + turnID: "turn-2", + itemID: "message-1", + delta: "Second" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + #expect(await eventually { + router.threadEventHub.snapshotForTesting(threadID: "thread-1").isClosed + }) + + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + let logs = try await collect(thread.logEntries) + let deltas = logs.filter { $0.phase == .delta } + #expect(deltas.map(\.id) == ["message-1:0"]) + #expect(deltas.compactMap(\.messageDelta).map(\.text) == ["Second"]) + } + + @Test func messageDeltaWithoutItemIDFailsAsMalformedNotification() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + let events = await harness.server.connectionEvents() + let terminal = Task { await firstConnectionTermination(in: events) } + await events.waitUntilNextSuspendsForTesting() + try await transport.emitServerNotification( + method: "item/agentMessage/delta", + params: MessageDeltaWithoutItemIDParams( + threadID: "thread-1", + turnID: "turn-1", + delta: "Missing item identity" + ) + ) + + let termination = try #require(await terminal.value) + guard case .transportFailure(.protocolViolation(let message, _)) = termination else { + Issue.record("Expected connection protocol violation, got \(termination).") + return + } + #expect(message.contains("item/agentMessage/delta")) + } + + @Test func threadItemWithoutIDFailsAsMalformedNotification() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + let events = await harness.server.connectionEvents() + let terminal = Task { await firstConnectionTermination(in: events) } + await events.waitUntilNextSuspendsForTesting() + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemWithoutIDParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init(type: "agentMessage", text: "Missing item identity") + ) + ) + + let termination = try #require(await terminal.value) + guard case .transportFailure(.protocolViolation(let message, _)) = termination else { + Issue.record("Expected connection protocol violation, got \(termination).") + return + } + #expect(message.contains("item/completed")) + } + + @Test func threadLogEntriesIncludeProgressDeltaNotifications() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + let logEntries = thread.logEntries + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "", + status: "inProgress" + ) + ) + try await transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: ItemOutputDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "command-1", + delta: "Compiling" + ) + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "patch-1", + type: "fileChange", + status: "inProgress", + changes: .array([]) + ) + ) + try await transport.emitServerNotification( + method: "item/fileChange/patchUpdated", + params: ItemPatchUpdatedParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "patch-1", + changes: .array([ + .object([ + "diff": .string("@@ -1 +1 @@"), + "kind": .object(["type": .string("update")]), + "path": .string("Sources/File.swift"), + ]), + ]) + ) + ) + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "tool-1", + type: "mcpToolCall", + status: "inProgress", + tool: "review" + ) + ) + try await transport.emitServerNotification( + method: "item/mcpToolCall/progress", + params: ItemProgressParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "tool-1", + message: "Reviewing" + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let logs = try await collect(logEntries) + let updates = logs.filter { $0.phase == .updated } + + #expect(updates.count == 3) + #expect(updates.allSatisfy { $0.turnID == "turn-1" }) + #expect(updates.contains { + guard case .command(let command) = $0.item?.content else { + return false + } + return command.command == "swift test" && command.output == "Compiling" + }) + #expect( + updates.contains { + if case .fileChange(let fileChange) = $0.item?.content { + fileChange.output == "@@ -1 +1 @@" + } else { + false + } + }) + #expect( + updates.contains { + if case .toolCall(let toolCall) = $0.item?.content { + toolCall.result == "Reviewing" + } else { + false + } + }) + } + + @Test func progressDeltaWithoutItemIDFailsAsMalformedNotification() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + let events = await harness.server.connectionEvents() + let terminal = Task { await firstConnectionTermination(in: events) } + await events.waitUntilNextSuspendsForTesting() + + try await transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: ItemOutputDeltaWithoutItemIDParams( + threadID: "thread-1", + turnID: "turn-1", + delta: "Compiling" + ) + ) + + let termination = try #require(await terminal.value) + guard case .transportFailure(.protocolViolation(let message, _)) = termination else { + Issue.record("Expected connection protocol violation, got \(termination).") + return + } + #expect(message.contains("item/commandExecution/outputDelta")) + } + + @Test func completedFileChangeItemsPreserveChangesOutput() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "file-1", + type: "fileChange", + status: "completed", + changes: .array([ + .object([ + "diff": .string("@@ -1 +1 @@"), + "kind": .object(["type": .string("update")]), + "path": .string("Sources/File.swift"), + ]), + ]) + ) + ) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let logs = try await collect(thread.logEntries) + + guard case .fileChange(let fileChange) = logs.first?.item?.content else { + Issue.record("Expected a structured file-change item.") + return + } + #expect(fileChange.path == "Sources/File.swift") + #expect(fileChange.output == "@@ -1 +1 @@") + } + + @Test func reasoningNotificationsRouteAsTypedEventsLogsAndTranscript() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + await transport.waitForNotificationStreamCount(1) + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + let eventsTask = Task { try await collect(thread.events) } + let logsTask = Task { try await collect(thread.logEntries) } + let transcriptsTask = Task { try await collect(thread.transcriptUpdates) } + #expect(await eventually { + router.threadSubscriberCountForTesting(for: "thread-1") == 3 + }) + + try await emitItemStarted( + on: transport, + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "reasoning-1", + type: "reasoning", + summary: [], + content: [] + ) + ) + try await transport.emitServerNotification( + method: "item/reasoning/summaryPartAdded", + params: ReasoningSummaryPartParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + summaryIndex: 0 + ) + ) + try await transport.emitServerNotification( + method: "item/reasoning/summaryTextDelta", + params: ReasoningSummaryDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + summaryIndex: 0, + delta: "Checking" + ) + ) + try await transport.emitServerNotification( + method: "item/reasoning/textDelta", + params: ReasoningTextDeltaParams( + threadID: "thread-1", + turnID: "turn-1", + itemID: "reasoning-1", + contentIndex: 1, + delta: "Raw trace" + ) + ) + try await transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "reasoning-1", + type: "reasoning", + summary: ["Final summary"], + content: ["Final raw"] + ) + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(turn: .init(id: "turn-1", status: "completed")) + ) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadIDParams(threadID: "thread-1") + ) + + let events = try await eventsTask.value + #expect( + events.contains { + if case .reasoningSummaryPartAdded(let part, let turnID) = $0 { + part.id == "reasoning-1:summary:0" && turnID == "turn-1" + } else { + false + } + }) + #expect( + events.contains { + if case .reasoningDelta(let delta, let turnID) = $0 { + delta.id == "reasoning-1:content:1" + && delta.delta == "Raw trace" + && turnID == "turn-1" + } else { + false + } + }) + + let logs = try await logsTask.value + #expect(logs.contains { $0.id == "reasoning-1:summary:0" && $0.phase == .started }) + #expect( + logs.contains { + $0.reasoningDelta?.id == "reasoning-1:summary:0" + && $0.reasoningDelta?.delta == "Checking" + }) + #expect( + logs.contains { + $0.reasoningDelta?.id == "reasoning-1:content:1" + && $0.reasoningDelta?.delta == "Raw trace" + }) + + let transcripts = try await transcriptsTask.value + let finalTranscript = try #require(transcripts.last) + #expect(finalTranscript.items.map(\.id) == ["reasoning-1"]) + #expect(finalTranscript.items.first?.content == .reasoning( + .init(summary: ["Final summary"], content: ["Final raw"]) + )) + } + + @Test func modelAndConfigurationDecodeReasoningTypes() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "data": [ + { + "id": "gpt-5-codex", + "model": "gpt-5-codex", + "displayName": "GPT-5 Codex", + "hidden": false, + "supportedReasoningEfforts": [ + {"reasoningEffort": "medium", "description": "Balanced"}, + {"reasoningEffort": "xhigh", "description": "Maximum"} + ], + "defaultReasoningEffort": "xhigh", + "additionalSpeedTiers": [], + "isDefault": true + } + ], + "nextCursor": null + } + """, + for: "model/list" + ) + try await transport.enqueueJSON( + """ + { + "config": { + "model": "gpt-5-codex", + "model_reasoning_effort": "high", + "service_tier": "flex" + } + } + """, + for: "config/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let models = try await server.models() + let reasoningEfforts = models.first?.supportedReasoningEfforts.map(\.reasoningEffort) + #expect(reasoningEfforts == [.medium, .xhigh]) + #expect(models.first?.defaultReasoningEffort == .xhigh) + + let configuration = try await server.configuration() + #expect(configuration.reasoningEffort == .high) + } + + @Test func updateConfigurationSendsBatchWriteEdits() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON(#"{"status":"ok"}"#, for: "config/batchWrite") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + var patch = CodexConfigurationPatch() + patch.setReviewModel("gpt-5-codex-review") + patch.setReasoningEffort(.high) + patch.setServiceTier("flex") + try await server.updateConfiguration(patch) + + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "config/batchWrite") + let params = try request.decodeParams(ConfigBatchWriteParams.self) + #expect(params.reloadUserConfig == true) + #expect(params.edits == [ + .init(keyPath: "review_model", value: .string("gpt-5-codex-review")), + .init(keyPath: "model_reasoning_effort", value: .string("high")), + .init(keyPath: "service_tier", value: .string("flex")), + ]) + } + + @Test func updateConfigurationSkipsEmptyPatch() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + try await server.updateConfiguration(.init()) + + #expect(await transport.recordedRequests().isEmpty) + } + + @Test func testRuntimeEnqueuesRateLimitResetTimesInAppServerSeconds() async throws { + let transport = CodexAppServerTestTransport() + let resetDate = Date(timeIntervalSince1970: 1_700_000_000) + try await transport.enqueueRateLimits(.init( + planType: "pro", + windows: [ + .init( + windowDurationMinutes: 300, + usedPercent: 42, + resetsAt: resetDate + ), + ] + )) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let rateLimits = try await server.rateLimits() + + #expect(rateLimits.planType == "pro") + #expect(rateLimits.windows == [ + .init( + windowDurationMinutes: 300, + usedPercent: 42, + resetsAt: resetDate + ), + ]) + } + + @Test func rateLimitsDecodeCoreWindowWireShape() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "rateLimits": { + "limitId": "codex", + "planType": "pro", + "primary": { + "used_percent": 87.5, + "window_minutes": 60, + "resets_at": 1700000000 + } + } + } + """, + for: "account/rateLimits/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let rateLimits = try await server.rateLimits() + + #expect(rateLimits.planType == "pro") + #expect(rateLimits.windows == [ + .init( + windowDurationMinutes: 60, + usedPercent: 88, + resetsAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + ]) + } + + @Test func accountReadAcceptsChatGPTAccountWithoutEmail() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + """ + { + "account": { + "type": "chatgpt", + "email": null, + "planType": "plus" + }, + "requiresOpenaiAuth": false + } + """, + for: "account/read" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let account = try #require(try await server.account()) + + #expect(account.kind == .chatGPT) + #expect(account.id == "chatgpt") + #expect(account.label == "ChatGPT") + #expect(account.planType == "plus") + } + + @Test func loginFlowUsesSupportedRequests() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + try await transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + let handle = try await server.loginChatGPT() + + #expect(handle.id == "login-1") + #expect(handle.authenticationURL == URL(string: "https://chatgpt.com/auth")!) + let loginRequest = try #require(await transport.recordedRequests().first) + #expect(loginRequest.method == "account/login/start") + let loginParams = try loginRequest.decodeParams(AppServerAPI.Account.Login.Params.self) + #expect(loginParams.type == "chatgpt") + #expect(loginParams.codexStreamlinedLogin == true) + + #expect(try await handle.cancel() == .cancelled) + let cancelRequest = try #require(await transport.recordedRequests().last) + #expect(cancelRequest.method == "account/login/cancel") + let cancelParams = try cancelRequest.decodeParams( + AppServerAPI.Account.Login.Cancel.Params.self + ) + #expect(cancelParams.loginID == "login-1") + + #expect(await transport.recordedRequests().map(\.method) == [ + "account/login/start", + "account/login/cancel", + ]) + } + + @Test func apiKeyLoginUsesAnExclusiveWireShapeWithoutExposingTheSecretSemantically() async throws { + let apiKey = "sk-test-api-key-secret-sentinel" + let transport = CodexAppServerTestTransport() + try await transport.enqueueAPIKeyLogin() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + try await harness.server.login(apiKey: apiKey) + + let request = try #require( + await transport.recordedRequests(method: "account/login/start").first + ) + guard case .apiKey(let encodedAPIKey) = try request.decodeParams( + AppServerAPI.Account.Login.Params.self + ) else { + Issue.record("Expected API-key login parameters.") + return + } + #expect(encodedAPIKey == apiKey) + + let object = try #require( + JSONSerialization.jsonObject(with: request.params) as? [String: Any] + ) + #expect(Set(object.keys) == Set(["type", "apiKey"])) + #expect(object["type"] as? String == "apiKey") + #expect(object["apiKey"] as? String == apiKey) + + let semanticRequest = try #require(await transport.recordedRequests().first?.request) + #expect(semanticRequest == .accountLoginStart) + #expect(String(reflecting: semanticRequest).contains(apiKey) == false) + await harness.close() + } + + @Test func loginParameterDecoderRejectsMixedProviderStates() throws { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + AppServerAPI.Account.Login.Params.self, + from: Data( + #"{"type":"apiKey","apiKey":"placeholder","codexStreamlinedLogin":true}"#.utf8 + ) + ) + } + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + AppServerAPI.Account.Login.Params.self, + from: Data( + #"{"type":"chatgpt","apiKey":"placeholder","codexStreamlinedLogin":true}"#.utf8 + ) + ) + } + } + + @Test func apiKeyLoginRejectsInvalidInputBeforeSendingARequest() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let invalidInputs: [(String, CodexAPIKeyValidationFailure)] = [ + ("", .empty), + (" \n\t", .empty), + (" sk-test-validation-leading", .surroundingWhitespace), + ("sk-test-validation-trailing\n", .surroundingWhitespace), + ] + + for (apiKey, expectedFailure) in invalidInputs { + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected invalid API-key input to fail.") + } catch let error as CodexAppServerError { + #expect(error == .invalidAPIKey(expectedFailure)) + let sensitiveValue = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if sensitiveValue.hasPrefix("sk-") { + #expect(error.localizedDescription.contains(sensitiveValue) == false) + } + } + } + + #expect(await transport.recordedRequests(method: "account/login/start").isEmpty) + await harness.close() + } + + @Test func apiKeyLoginRejectsAnUnexpectedProviderResponseWithoutExposingTheSecret() async throws { + let apiKey = "sk-test-unexpected-response-secret" + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "unexpected-login", + authenticationURL: URL(string: "https://example.test/\(apiKey)")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected the provider mismatch to require reconciliation.") + } catch let error as CodexAppServerError { + #expect(error == .authenticationOutcomeUnknown(.unexpectedResponse)) + #expect(error.localizedDescription.contains(apiKey) == false) + #expect(String(reflecting: error).contains(apiKey) == false) + } + await harness.close() + } + + @Test func apiKeyLoginSanitizesAnInvalidResponseThatEchoesTheSecret() async throws { + let apiKey = "sk-test-invalid-response-secret" + let transport = CodexAppServerTestTransport() + try await transport.enqueueJSON( + #"{"type":42,"echo":"\#(apiKey)"}"#, + for: "account/login/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected the invalid response to require reconciliation.") + } catch let error as CodexAppServerError { + #expect(error == .authenticationOutcomeUnknown(.invalidResponse)) + #expect(error.localizedDescription.contains(apiKey) == false) + #expect(String(reflecting: error).contains(apiKey) == false) + } + await harness.close() + } + + @Test func apiKeyLoginKeepsExplicitServerRejectionKnownAndSecretFree() async throws { + let apiKey = "sk-test-server-rejection-secret" + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_000, + message: "rejected \(apiKey)", + for: "account/login/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected an explicit server rejection.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind else { + Issue.record("Expected a known server rejection, got \(error).") + return + } + #expect(serverError.code == -32_000) + #expect(serverError.message == "API-key login was rejected by the app-server.") + #expect(error.localizedDescription.contains(apiKey) == false) + #expect(String(reflecting: error).contains(apiKey) == false) + } + await harness.close() + } + + @Test func apiKeyLoginPostWriteCancellationDoesNotHideAServerRejection() async throws { + let apiKey = "sk-test-cancelled-server-rejection-secret" + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + await transport.enqueueFailure( + code: -32_000, + message: "rejected \(apiKey)", + for: "account/login/start" + ) + await transport.holdNextIgnoringCancellation( + method: "account/login/start", + gate: responseGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let task = Task { + try await harness.server.login(apiKey: apiKey) + } + await transport.waitForRequest(method: "account/login/start") + + task.cancel() + await responseGate.open() + + do { + try await task.value + Issue.record("Expected the correlated server rejection.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind else { + Issue.record("Expected a known server rejection, got \(error).") + return + } + #expect(serverError.code == -32_000) + #expect(error.localizedDescription.contains(apiKey) == false) + } + await harness.close() + } + + @Test func apiKeyLoginDoesNotRetryOrPublishAnOverloadResponseContainingTheSecret() async throws { + let apiKey = "sk-test-overload-secret" + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_001, + message: "overloaded \(apiKey)", + for: "account/login/start" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected an explicit overload rejection.") + } catch let error as CodexAppServerError { + guard case .request(let failure) = error, + case .server(let serverError) = failure.kind else { + Issue.record("Expected a known overload rejection, got \(error).") + return + } + #expect(serverError.code == -32_001) + #expect(error.localizedDescription.contains(apiKey) == false) + #expect(String(reflecting: error).contains(apiKey) == false) + } + #expect(await transport.recordedRequests(method: "account/login/start").count == 1) + await harness.close() + } + + @Test func apiKeyLoginCancellationBeforeWriteHasNoWireEffect() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueAPIKeyLogin() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let startGate = CodexAppServerTestGate() + let task = Task { + await startGate.waitIgnoringCancellation() + try await harness.server.login(apiKey: "sk-test-pre-write-cancellation") + } + await startGate.waitUntilBlocked() + + task.cancel() + await startGate.open() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(await transport.recordedRequests(method: "account/login/start").isEmpty) + await harness.close() + } + + @Test func apiKeyLoginDefersPostWriteCancellationUntilTheResponseIsKnown() async throws { + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + try await transport.enqueueAPIKeyLogin() + await transport.holdNextIgnoringCancellation( + method: "account/login/start", + gate: responseGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let task = Task { + try await harness.server.login(apiKey: "sk-test-post-write-cancellation") + } + await transport.waitForRequest(method: "account/login/start") + + task.cancel() + await responseGate.open() + + try await task.value + #expect(await transport.recordedRequests(method: "account/login/start").count == 1) + #expect(await transport.recordedRequests(method: "account/login/cancel").isEmpty) + await harness.close() + } + + @Test func apiKeyLoginDeadlineAfterWriteRequiresReconciliation() async throws { + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + let deadlineClock = CodexAppServerTestDeadlineClock() + try await transport.enqueueAPIKeyLogin() + await transport.holdNextIgnoringCancellation( + method: "account/login/start", + gate: responseGate + ) + let runtime = try await CodexAppServerTestRuntime.start( + transport: transport, + configuration: .init(deadlines: .init(request: .seconds(5))), + deadlineClock: deadlineClock + ) + let task = Task { + try await runtime.server.login(apiKey: "sk-test-deadline") + } + await transport.waitForRequest(method: "account/login/start") + try await deadlineClock.waitForSleeperCount(1) + + deadlineClock.advance(by: .seconds(5)) + + do { + try await task.value + Issue.record("Expected an unknown authentication outcome after the deadline.") + } catch let error as CodexAppServerError { + #expect(error == .authenticationOutcomeUnknown(.deadlineExceeded(.seconds(5)))) + } + #expect(await transport.isClosedForTesting()) + await runtime.close() + } + + @Test func apiKeyLoginWriteFailureAfterAcceptanceRequiresReconciliation() async throws { + let apiKey = "sk-test-post-write-transport-secret" + let transport = TestOutboundWriteFailureTransport( + failure: .contractViolation(message: "failed after writing \(apiKey)") + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + do { + try await harness.server.login(apiKey: apiKey) + Issue.record("Expected transport reconciliation after write acceptance.") + } catch let error as CodexAppServerError { + #expect(error == .authenticationOutcomeUnknown(.transportEnded)) + #expect(error.localizedDescription.contains(apiKey) == false) + #expect(String(reflecting: error).contains(apiKey) == false) + } + await harness.close() + } + + @Test func apiKeyLoginConnectionTerminationAfterWriteRequiresReconciliation() async throws { + let apiKey = "sk-test-connection-secret" + let transport = CodexAppServerTestTransport() + let responseGate = CodexAppServerTestGate() + try await transport.enqueueAPIKeyLogin() + await transport.holdNextIgnoringCancellation( + method: "account/login/start", + gate: responseGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let task = Task { + try await harness.server.login(apiKey: apiKey) + } + await transport.waitForRequest(method: "account/login/start") + + await transport.failConnection(.closed) + await responseGate.open() + + do { + try await task.value + Issue.record("Expected connection reconciliation.") + } catch let error as CodexAppServerError { + #expect(error == .authenticationOutcomeUnknown(.connectionTerminated)) + #expect(error.localizedDescription.contains(apiKey) == false) + } + await harness.close() + } + + @Test func stockChatGPTLoginResolvesAfterPostSuccessAccountUpdate() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let login = try await server.loginChatGPT() + + #expect(login.id == "login-1") + #expect(login.authenticationURL == URL(string: "https://chatgpt.com/auth")!) + let loginRequest = try #require(await transport.recordedRequests().first) + #expect(loginRequest.method == "account/login/start") + let loginParams = try loginRequest.decodeParams(AppServerAPI.Account.Login.Params.self) + #expect(loginParams.type == "chatgpt") + #expect(loginParams.codexStreamlinedLogin == true) + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + #expect(try await login.result() == .succeeded) + #expect(try await login.result() == .succeeded) + #expect(await transport.recordedRequests().map(\.method) == ["account/login/start"]) + } + + @Test func stockChatGPTLoginPreservesAccountUpdateReceivedBeforeSuccess() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let login = try await harness.server.loginChatGPT() + + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + + #expect(try await login.result() == .succeeded) + } + + @Test func stockChatGPTLoginIgnoresSparseAccountUpdateWhileAwaitingReadiness() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let login = try await harness.server.loginChatGPT() + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"planType":"plus"}"# + ) + await #expect(throws: CodexAppServerError.loginAlreadyInProgress) { + _ = try await harness.server.loginChatGPT() + } + + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + #expect(try await login.result() == .succeeded) + } + + @Test func stockChatGPTLoginRejectsExplicitNonChatGPTAccountAfterSuccess() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let login = try await harness.server.loginChatGPT() + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"apikey"}"# + ) + + #expect( + try await login.result() == .authenticationCommittedNeedsConnectionReconciliation( + .chatGPTAccountUnavailableAfterSuccess + ) + ) + } + + @Test func stockChatGPTLoginDropsMismatchedCompletionID() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"other-login","success":false,"error":"stale"}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + + #expect(try await handle.result() == .succeeded) + } + + @Test func stockChatGPTLoginReleasesReservationAfterStartFailure() async throws { + let transport = CodexAppServerTestTransport() + await transport.enqueueFailure( + code: -32_000, + message: "login unavailable", + for: "account/login/start" + ) + try await transport.enqueueChatGPTLogin( + loginID: "login-2", + authenticationURL: URL(string: "https://chatgpt.com/auth/2")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + + await #expect(throws: CodexAppServerError.self) { + _ = try await harness.server.loginChatGPT() + } + let handle = try await harness.server.loginChatGPT() + + #expect(handle.id == "login-2") + #expect(await transport.recordedRequests(method: "account/login/start").count == 2) + } + + @Test func stockChatGPTLoginRejectsConcurrentStartBeforeSendingASecondRequest() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let activeHandle = try await harness.server.loginChatGPT() + + do { + _ = try await harness.server.loginChatGPT() + Issue.record("Expected the active login reservation to reject a second start.") + } catch let error as CodexAppServerError { + #expect(error == .loginAlreadyInProgress) + } + #expect(await transport.recordedRequests(method: "account/login/start").count == 1) + _ = activeHandle + } + + @Test func stockChatGPTLoginRegistryRetainsPendingStateAfterHandleIsDropped() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth/1")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + + let authenticationURL = try await server.loginChatGPT().authenticationURL + #expect(authenticationURL == URL(string: "https://chatgpt.com/auth/1")!) + await #expect(throws: CodexAppServerError.loginAlreadyInProgress) { + _ = try await server.loginChatGPT() + } + #expect(await transport.recordedRequests(method: "account/login/start").count == 1) + + await harness.router.loginRegistry.apply( + .init(loginID: "login-1", success: true) + ) + await harness.router.loginRegistry.applyAccountUpdate( + .init(authMode: .chatGPT, planType: .plus) + ) + try await transport.enqueueChatGPTLogin( + loginID: "login-2", + authenticationURL: URL(string: "https://chatgpt.com/auth/2")! + ) + + let nextHandle = try await server.loginChatGPT() + + #expect(nextHandle.id == "login-2") + #expect(await transport.recordedRequests(method: "account/login/start").count == 2) + } + + @Test func stockChatGPTLoginCancellationIsSharedAcrossConcurrentCallers() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + try await transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + + async let first = handle.cancel() + async let second = handle.cancel() + + #expect(try await first == .cancelled) + #expect(try await second == .cancelled) + #expect(await transport.recordedRequests(method: "account/login/cancel").count == 1) + } + + @Test func unknownPendingLoginCancelOutcomeRequiresReconciliation() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + await transport.enqueueFailure( + code: -32_000, + message: "cancel response lost", + for: "account/login/cancel" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + + let outcome = try await handle.cancel(acknowledgementTimeout: .seconds(5)) + + guard case .authenticationCommittedNeedsConnectionReconciliation( + .cancelOutcomeUnknown(let failure) + ) = outcome else { + Issue.record("Expected an unknown pending cancel outcome to require reconciliation.") + return + } + #expect(failure?.method == "account/login/cancel") + #expect(try await handle.result() == outcome) + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + #expect(try await handle.result() == outcome) + } + + @Test func pendingLoginConnectionFailureWhileCancellationIsInFlightRequiresReconciliation() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + await transport.enqueueFailure( + code: -32_000, + message: "cancel response lost", + for: "account/login/cancel" + ) + let cancelGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + method: "account/login/cancel", + gate: cancelGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + let cancellation = Task { + try await handle.cancel(acknowledgementTimeout: .seconds(5)) + } + await transport.waitForRequest(method: "account/login/cancel") + let result = Task { try await handle.result() } + + await transport.failConnection(.closed) + let terminalResult = await result.result + await cancelGate.open() + let cancellationResult = await cancellation.result + + let expected = CodexLoginOutcome.authenticationCommittedNeedsConnectionReconciliation( + .cancelOutcomeUnknown(nil) + ) + switch terminalResult { + case .success(let outcome): + #expect(outcome == expected) + case .failure(let error): + Issue.record("Expected connection reconciliation, got terminal failure: \(error)") + } + switch cancellationResult { + case .success(let outcome): + #expect(outcome == expected) + case .failure(let error): + Issue.record("Expected shared cancellation reconciliation, got failure: \(error)") + } + await harness.close() + } + + @Test func loginSuccessTerminalWinsOverLateCancelResponse() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + try await transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) + let cancelGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + method: "account/login/cancel", + gate: cancelGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + let cancellation = Task { + try await handle.cancel(acknowledgementTimeout: .seconds(5)) + } + await transport.waitForRequest(method: "account/login/cancel") + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + #expect(try await handle.result() == .succeeded) + await cancelGate.open() + + #expect(try await cancellation.value == .succeeded) + } + + @Test func loginFailureTerminalWinsOverLateCancelResponse() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + try await transport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) + let cancelGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + method: "account/login/cancel", + gate: cancelGate + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + let cancellation = Task { + try await handle.cancel(acknowledgementTimeout: .seconds(5)) + } + await transport.waitForRequest(method: "account/login/cancel") + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":false,"error":"denied"}"# + ) + #expect(try await handle.result() == .failed(message: "denied")) + await cancelGate.open() + + #expect(try await cancellation.value == .failed(message: "denied")) + } + + @Test func cancellingLoginResultWaiterDoesNotCancelSharedLogin() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + let cancelledWaiter = Task { try await handle.result() } + + cancelledWaiter.cancel() + await #expect(throws: CancellationError.self) { + try await cancelledWaiter.value + } + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":"chatgpt","planType":"plus"}"# + ) + + #expect(try await handle.result() == .succeeded) + #expect(await transport.recordedRequests(method: "account/login/cancel").isEmpty) + } + + @Test func stockChatGPTLoginReadinessDeadlineStartsAfterSuccess() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let deadlineGate = CodexAppServerTestGate() + let deadlineStarted = TestSignal() + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { duration in + #expect(duration == .seconds(5)) + deadlineStarted.signal() + await deadlineGate.waitIgnoringCancellation() + } + ) + let handle = try await harness.server.loginChatGPT(accountReadinessTimeout: .seconds(5)) + let result = Task { try await handle.result() } + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + await deadlineStarted.wait() + await deadlineGate.open() + + #expect( + try await result.value == .authenticationCommittedNeedsConnectionReconciliation( + .accountReadinessDeadlineExceeded(.seconds(5)) + ) + ) + } + + @Test func malformedAccountUpdateAfterLoginSuccessRequiresReconciliation() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + try await transport.emitServerNotificationJSON( + method: "account/updated", + json: #"{"authMode":42}"# + ) + + guard case .authenticationCommittedNeedsConnectionReconciliation( + .malformedAccountUpdateAfterSuccess(let malformed) + ) = try await handle.result() else { + Issue.record("Expected malformed post-success account update reconciliation.") + return + } + #expect(malformed.method == "account/updated") + } + + @Test func unknownCancelOutcomeAfterLoginSuccessRequiresReconciliation() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueueChatGPTLogin( + loginID: "login-1", + authenticationURL: URL(string: "https://chatgpt.com/auth")! + ) + await transport.enqueueFailure( + code: -32_000, + message: "cancel response lost", + for: "account/login/cancel" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let handle = try await harness.server.loginChatGPT() + try await transport.emitServerNotificationJSON( + method: "account/login/completed", + json: #"{"loginId":"login-1","success":true,"error":null}"# + ) + + guard case .authenticationCommittedNeedsConnectionReconciliation( + .cancelOutcomeUnknown(let failure) + ) = try await handle.cancel(acknowledgementTimeout: .seconds(5)) else { + Issue.record("Expected unknown post-success cancel reconciliation.") + return + } + #expect(failure?.method == "account/login/cancel") + } + + @Test func responseStreamCancelSendsTurnInterrupt() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + try await stream.cancel() + + #expect( + await transport.recordedRequests().map(\.method) == [ + "turn/start", + "turn/interrupt", + ]) + let request = try #require(await transport.recordedRequests().last) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Interrupt.Params.self, from: request.params) + #expect(params.threadID == "thread-1") + #expect(params.turnID == "turn-1") + } + + @Test func threadCancelActiveTurnSendsExpectedTurnID() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let cancellation = try await thread.cancelActiveTurn(expectedTurnID: "turn-1") + + #expect(cancellation.threadID == "thread-1") + #expect(cancellation.turnID == "turn-1") + let request = try #require(await transport.recordedRequests().first) + #expect(request.method == "turn/interrupt") + let params = try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self) + #expect(params.threadID == "thread-1") + #expect(params.turnID == "turn-1") + } + + @Test func responseStreamCancelRetriesWithCurrentActiveTurnID() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-old", status: "running")), + for: "turn/start" + ) + await transport.enqueueFailure( + code: -32602, + message: "expected active turn id turn-old but found turn-new", + for: "turn/interrupt" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + let cancellation = try await stream.cancel() + + #expect(cancellation.threadID == "thread-1") + #expect(cancellation.turnID == "turn-new") + let cancelRequests = await transport.recordedRequests().filter { + $0.method == "turn/interrupt" + } + let turnIDs = try cancelRequests.map { request in + try request.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(turnIDs == ["turn-old", "turn-new"]) + } + + @Test func responseStreamCancelRetriesUntilExpectedTurnIsActive() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + await transport.enqueueFailure( + code: -32602, + message: "no active turn to interrupt", + for: "turn/interrupt" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { _ in } + ) + let client = harness.client + let router = harness.router + let thread = CodexThread(id: "thread-1", client: client, router: router, connectionLease: harness.lease) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + let cancellation = try await stream.cancel() + + #expect(cancellation.threadID == "thread-1") + #expect(cancellation.turnID == "turn-1") + let turnIDs = try await transport.recordedRequests(method: "turn/interrupt").map { + try $0.decodeParams(AppServerAPI.Turn.Interrupt.Params.self).turnID + } + #expect(turnIDs == ["turn-1", "turn-1"]) + } + + @Test func responseStreamSteerSubmitsInputToCurrentTurn() async throws { + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + try await transport.enqueue( + AppServerAPI.Turn.Steer.Response(turnID: "turn-1"), + for: "turn/steer" + ) + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let client = harness.client + let router = harness.router + let thread = CodexThread( + id: "thread-1", + client: client, + router: router, + connectionLease: harness.lease + ) + + let stream = try await thread.streamResponse(to: "Run the slow checks.") + try await stream.steer(with: "Prefer the smallest fix.") + + #expect(await transport.recordedRequests(method: "turn/start").count == 1) + let request = try #require( + await transport.recordedRequests(method: "turn/steer").last + ) + let params = try JSONDecoder().decode( + AppServerAPI.Turn.Steer.Params.self, from: request.params) + #expect(params.threadID == "thread-1") + #expect(params.expectedTurnID == "turn-1") + #expect(params.input == [.text("Prefer the smallest fix.")]) + } + +} + +private func collect( + _ sequence: Sequence +) async throws -> [Sequence.Element] { + var elements: [Sequence.Element] = [] + for try await element in sequence { + elements.append(element) + } + return elements +} + +private struct ConfigBatchWriteParams: Decodable, Equatable { + var edits: [Edit] + var reloadUserConfig: Bool + + struct Edit: Decodable, Equatable { + var keyPath: String + var value: AppServerJSONValue + var mergeStrategy: String + + init( + keyPath: String, + value: AppServerJSONValue, + mergeStrategy: String = "replace" + ) { + self.keyPath = keyPath + self.value = value + self.mergeStrategy = mergeStrategy + } + } +} + +private struct TurnIDParams: Encodable, Sendable { + var turnID: String + + enum CodingKeys: String, CodingKey { + case turnID = "turnId" + } +} + +private struct ThreadIDParams: Encodable, Sendable { + var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +private struct LoginCompletedParams: Encodable, Sendable { + var loginID: String? + var success: Bool + var error: String? + + enum CodingKeys: String, CodingKey { + case loginID = "loginId" + case success + case error + } + + init(loginID: String? = nil, success: Bool, error: String? = nil) { + self.loginID = loginID + self.success = success + self.error = error + } +} + +private struct ReviewErrorParams: Encodable, Sendable { + var threadID: String + var turnID: String + var error: ErrorPayload + var willRetry: Bool + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case error + case willRetry + } + + struct ErrorPayload: Encodable, Sendable { + var message: String + } +} + +private struct TurnDeltaParams: Encodable, Sendable { + var threadID: String = "thread-1" + var turnID: String + var itemID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case delta + } +} + +private struct AgentMessageParams: Encodable, Sendable { + var threadID: String = "thread-1" + var turnID: String + var itemID: String? = "message-1" + var message: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case message + } +} + +private struct MessageDeltaWithoutItemIDParams: Encodable, Sendable { + var threadID: String = "thread-1" + var turnID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case delta + } +} + +private struct TurnStartedParams: Encodable, Sendable { + var threadID: String + var turn: AppServerAPI.Turn.Payload + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + init(threadID: String, turnID: String) { + self.threadID = threadID + self.turn = .init(id: turnID, status: "inProgress", items: []) + } +} + +private struct ItemOutputDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case delta + } +} + +private struct ItemOutputDeltaWithoutItemIDParams: Encodable, Sendable { + var threadID: String + var turnID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case delta + } +} + +private struct ItemPatchUpdatedParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var changes: AppServerJSONValue + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case changes + } +} + +private struct ItemProgressParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var message: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case message + } +} + +private struct TurnCompletedParams: Encodable, Sendable { + var threadID: String + var turn: AppServerAPI.Turn.Payload + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + init(threadID: String = "thread-1", turn: AppServerAPI.Turn.Payload) { + self.threadID = threadID + var turn = turn + turn.items = turn.items ?? [] + self.turn = turn + } +} + +private struct ThreadItemParams: Encodable, Sendable { + var threadID: String + var turnID: String + var item: Item + var startedAtMS: Int64 = 0 + var completedAtMS: Int64 = 0 + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case item + case startedAtMS = "startedAtMs" + case completedAtMS = "completedAtMs" + } + + struct Item: Encodable, Sendable { + var id: String + var type: String + var text: String? + var phase: String? + var command: String? + var aggregatedOutput: String? + var status: String? + var path: String? + var tool: String? + var summary: [String]? + var content: [String]? + var contentItems: [TextContent]? + var changes: AppServerJSONValue? + + init( + id: String, + type: String, + text: String? = nil, + phase: String? = nil, + command: String? = nil, + aggregatedOutput: String? = nil, + status: String? = nil, + path: String? = nil, + tool: String? = nil, + summary: [String]? = nil, + content: [String]? = nil, + contentItems: [TextContent]? = nil, + changes: AppServerJSONValue? = nil + ) { + self.id = id + self.type = type + self.text = text + self.phase = phase + self.command = command + self.aggregatedOutput = aggregatedOutput + self.status = status + self.path = path + self.tool = tool + self.summary = summary + self.content = content + self.contentItems = contentItems + self.changes = changes + } + + enum CodingKeys: String, CodingKey { + case id + case type + case text + case phase + case command + case aggregatedOutput + case status + case path + case tool + case summary + case content + case changes + case cwd + case commandActions + case server + case arguments + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(phase, forKey: .phase) + try container.encodeIfPresent(command, forKey: .command) + try container.encodeIfPresent(aggregatedOutput, forKey: .aggregatedOutput) + try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(tool, forKey: .tool) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(changes, forKey: .changes) + if type == "commandExecution" { + try container.encode("/workspace", forKey: .cwd) + try container.encode([String](), forKey: .commandActions) + } + if type == "mcpToolCall" { + try container.encode("server", forKey: .server) + try container.encode(AppServerJSONValue.object([:]), forKey: .arguments) + } + if type == "fileChange", changes == nil { + try container.encode(AppServerJSONValue.array([]), forKey: .changes) + } + if let contentItems { + try container.encode(contentItems, forKey: .content) + } else { + try container.encodeIfPresent(content, forKey: .content) + } + } + + struct TextContent: Encodable, Sendable { + var type = "text" + var text: String + } + } +} + +private struct ThreadItemWithoutIDParams: Encodable, Sendable { + var threadID: String + var turnID: String + var item: Item + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case item + } + + struct Item: Encodable, Sendable { + var type: String + var text: String? + } +} + +private struct ReasoningSummaryPartParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var summaryIndex: Int + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case summaryIndex + } +} + +private struct ReasoningSummaryDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var summaryIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case summaryIndex + case delta + } +} + +private struct ReasoningTextDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var contentIndex: Int + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case contentIndex + case delta + } +} + +private struct TokenUsageParams: Encodable, Sendable { + var threadID: String + var turnID: String + var tokenUsage: TokenUsage + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case tokenUsage + } + + struct TokenUsage: Encodable, Sendable { + var last: Breakdown + var total: Breakdown + var modelContextWindow: Int? + + init(total: Breakdown, modelContextWindow: Int? = nil) { + self.last = total + self.total = total + self.modelContextWindow = modelContextWindow + } + } + + struct Breakdown: Encodable, Sendable { + var cachedInputTokens: Int = 0 + var inputTokens: Int + var outputTokens: Int + var reasoningOutputTokens: Int = 0 + var totalTokens: Int + } +} + +private actor CancellationRecorder { + private var cancellations: [CodexTurnCancellation] = [] + + func append(_ cancellation: CodexTurnCancellation) { + cancellations.append(cancellation) + } + + func values() -> [CodexTurnCancellation] { + cancellations + } +} + +private actor ServerRequestRecorder { + private var recordedRequests: [CodexAppServerRequest] = [] + + func append(_ request: CodexAppServerRequest) { + recordedRequests.append(request) + } + + func requests() -> [CodexAppServerRequest] { + recordedRequests + } +} + +private final class TestPreWriteSuspendingTransport: JSONRPC.Transport, Sendable { + let connectionEventHub = ConnectionEventHub() + private let response: Data + private let sendEntered = TestSignal() + private let writeAcceptanceGate = CodexAppServerTestGate() + private let requestCount = Mutex(0) + + init(response: Data) { + self.response = response + } + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + sendEntered.signal() + await writeAcceptanceGate.waitIgnoringCancellation() + try acceptWrite() + requestCount.withLock { $0 += 1 } + return response + } + + func waitUntilSendEntered() async { + await sendEntered.wait() + } + + func allowWriteAcceptance() async { + await writeAcceptanceGate.open() + } + + func wireRequestCount() -> Int { + requestCount.withLock { $0 } + } + + func notify(_ notification: JSONRPC.Notification) async throws {} + + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { nil } + + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws {} + + func beginClose() async -> JSONRPC.ProcessExitObservation? { + await writeAcceptanceGate.open() + return nil + } + + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async {} + + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { .unavailable } + + func waitUntilClosed() async {} + + func reapProcess() async {} +} + +private final class TestOutboundWriteFailureTransport: JSONRPC.Transport, Sendable { + let connectionEventHub = ConnectionEventHub() + private let failure: CodexTransportFailure + private let inboundGate = CodexAppServerTestGate() + + init(failure: CodexTransportFailure) { + self.failure = failure + } + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + try acceptWrite() + throw JSONRPC.OutboundWriteFailure(failure) + } + + func notify(_ notification: JSONRPC.Notification) async throws {} + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + await inboundGate.waitIgnoringCancellation() + return nil + } + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws {} + func beginClose() async -> JSONRPC.ProcessExitObservation? { + await inboundGate.open() + return nil + } + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async {} + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { .unavailable } + func waitUntilClosed() async {} + func reapProcess() async {} +} + +private final class TestSuspendingTransport: JSONRPC.Transport, Sendable { + let connectionEventHub = ConnectionEventHub() + let response: Data + private let started = TestSignal() + private let cancelled: TestSignal + private let suspension: TestCancellationWaiter + + init(response: Data) { + self.response = response + let cancelled = TestSignal() + self.cancelled = cancelled + self.suspension = TestCancellationWaiter { + cancelled.signal() + } + } + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + try acceptWrite() + started.signal() + try await suspension.wait() + return response + } + + func waitUntilStarted() async { + await started.wait() + } + + func waitUntilCancelled() async { + await cancelled.wait() + } + + func notify(_ notification: JSONRPC.Notification) async throws {} + + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { nil } + + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws {} + + func beginClose() async -> JSONRPC.ProcessExitObservation? { + suspension.cancel() + return nil + } + + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async {} + + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { .unavailable } + + func waitUntilClosed() async {} + + func reapProcess() async {} +} + +private final class TestSignal: Sendable { + private struct State { + var isSignalled = false + var waiters: [UUID: CheckedContinuation] = [:] + } + + private let state = Mutex(State()) + + func wait() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isSignalled || Task.isCancelled { + return true + } + state.waiters[waiterID] = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } onCancel: { + let waiter = state.withLock { state in + state.waiters.removeValue(forKey: waiterID) + } + waiter?.resume() + } + } + + func signal() { + let waiters = state.withLock { state in + state.isSignalled = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resume() + } + } +} + +private final class TestCancellationWaiter: Sendable { + private struct State { + var continuation: CheckedContinuation? + var isCancelled = false + } + + private let state = Mutex(State()) + private let onCancel: @Sendable () -> Void + + init(onCancel: @escaping @Sendable () -> Void) { + self.onCancel = onCancel + } + + func wait() async throws { + try Task.checkCancellation() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isCancelled { + return true + } + precondition(state.continuation == nil) + state.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } onCancel: { + cancel() + } + try Task.checkCancellation() + } + + func cancel() { + onCancel() + let continuation = state.withLock { state in + state.isCancelled = true + let continuation = state.continuation + state.continuation = nil + return continuation + } + continuation?.resume() + } +} + +private func firstConnectionTermination( + in events: CodexConnectionEvents +) async -> CodexConnectionTermination? { + var iterator = events.makeAsyncIterator() + while let event = await iterator.next() { + if case .terminated(let termination) = event { + return termination + } + } + return nil +} + +private func emitItemStarted( + on transport: CodexAppServerTestTransport, + threadID: String, + turnID: String, + item: ThreadItemParams.Item +) async throws { + try await transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams(threadID: threadID, turnID: turnID, item: item) + ) +} + +private extension Array where Element == CodexThreadEvent { + func containsThreadText(_ text: String, turnID expectedTurnID: CodexTurnID) -> Bool { + contains { event in + switch event { + case .snapshot(let snapshot): + snapshot.id == expectedTurnID && snapshot.items.contains { $0.text == text } + case .itemStarted(let item, let turnID), + .itemUpdated(let item, let turnID), + .itemCompleted(let item, let turnID): + turnID == expectedTurnID && item.text == text + case .message(let message, let turnID): + turnID == expectedTurnID && message.text == text + case .messageDelta(let delta, let turnID): + turnID == expectedTurnID && delta.text == text + case .turnStarted, .terminal, .reasoningSummaryPartAdded, .reasoningDelta, + .diagnostic, .tokenUsageUpdated, .statusChanged, .closed, .unknown: + false + } + } + } +} + +private func eventually( + attempts: Int = 50, + _ condition: () async -> Bool +) async -> Bool { + for _ in 0.. ConnectionCloseAction { + ConnectionCloseAction(action: { + _ = await transport.beginClose() + }) +} + +private enum TestTimeoutError: Error { + case timedOut +} + +private func prepareRestartToken( + runtime: CodexAppServerTestRuntime, + identity: CodexReviewIdentity +) async throws -> CodexReviewRestartToken { + try await runtime.transport.enqueueThreadResume(.init(id: identity.activeTurnThreadID)) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + let expectedInterruptRequestCount = await runtime.transport + .recordedRequests(method: "turn/interrupt").count + 1 + let prepareTask = Task { + try await runtime.server.prepareReviewRestart(identity) + } + defer { + prepareTask.cancel() + } + await runtime.transport.waitForRequest( + method: "turn/interrupt", + count: expectedInterruptRequestCount + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: identity.activeTurnThreadID.rawValue, + turn: .init(id: identity.turnID.rawValue, status: "interrupted") + ) + ) + return try await prepareTask.value +} + +private func withTimeout( + _ timeout: Duration = .seconds(1), + operation: @escaping @Sendable () async throws -> Value +) async throws -> Value { + try await withThrowingTaskGroup(of: Value.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(for: timeout) + throw TestTimeoutError.timedOut + } + let value = try await group.next()! + group.cancelAll() + return value + } +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerRequestCodecTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerRequestCodecTests.swift new file mode 100644 index 00000000..a80e27c5 --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexAppServerRequestCodecTests.swift @@ -0,0 +1,374 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit + +@Suite("Typed server-request codec") +struct CodexAppServerRequestCodecTests { + private let codec = CodexAppServerRequestCodec() + + @Test func serverRequestIDsPreserveIntegerAndStringJSONTypes() throws { + for id in [CodexServerRequestID.integer(Int64.max), .string("request-7")] { + let encoded = try JSONEncoder().encode(id) + #expect(try JSONDecoder().decode(CodexServerRequestID.self, from: encoded) == id) + + let payload = try AppServerProcessTransport.serverRequestResponsePayload( + id: id, + response: .result(Data("{}".utf8)) + ) + let object = try #require( + JSONSerialization.jsonObject(with: payload) as? [String: Any] + ) + switch id { + case .integer(let value): + #expect((object["id"] as? NSNumber)?.int64Value == value) + case .string(let value): + #expect(object["id"] as? String == value) + } + } + } + + @Test func decodesCurrentV2InventoryAndPreservesUnknown() throws { + let fixtures: [(String, String, RequestKind)] = [ + ( + "item/commandExecution/requestApproval", + #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","startedAtMs":1,"availableDecisions":["decline",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","status"]}}]}"#, + .commandApproval + ), + ( + "item/fileChange/requestApproval", + #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","startedAtMs":2,"grantRoot":"/tmp"}"#, + .fileApproval + ), + ( + "item/tool/requestUserInput", + #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","questions":[{"id":"q1","header":"Choice","question":"Pick one","isOther":false,"isSecret":false,"options":null}],"autoResolutionMs":1000}"#, + .userInput + ), + ( + "mcpServer/elicitation/request", + #"{"threadId":"thread-1","turnId":null,"serverName":"server","mode":"form","_meta":null,"message":"Value?","requestedSchema":{"type":"object"}}"#, + .mcpElicitation + ), + ( + "item/permissions/requestApproval", + #"{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","environmentId":null,"startedAtMs":3,"cwd":"/tmp","reason":null,"permissions":{}}"#, + .permissions + ), + ( + "item/tool/call", + #"{"threadId":"thread-1","turnId":"turn-1","callId":"call-1","namespace":null,"tool":"lookup","arguments":{"key":"value"}}"#, + .dynamicToolCall + ), + ( + "account/chatgptAuthTokens/refresh", + #"{"reason":"unauthorized","previousAccountId":"account-1"}"#, + .tokenRefresh + ), + ("attestation/generate", #"{}"#, .attestation), + ("currentTime/read", #"{"threadId":"thread-1"}"#, .currentTime), + ("future/method", #"{"opaque":true}"#, .unknown), + ] + + for (method, json, expectedKind) in fixtures { + let data = Data(json.utf8) + let request = try codec.decode(method: method, params: data) + #expect(kind(of: request) == expectedKind) + #expect(request.method == method) + if case .commandExecutionApproval(let approval) = request { + #expect(approval.availableDecisions == [ + .decline, + .acceptWithExecpolicyAmendment(.init(command: ["git", "status"])), + ]) + } + if case .mcpElicitation(let elicitation) = request { + guard case .form(_, let message, let schema) = elicitation.elicitation else { + Issue.record("Expected the MCP form tagged variant.") + continue + } + #expect(message == "Value?") + #expect(schema == .object(["type": .string("object")])) + } + if case .unknown(let raw) = request { + #expect(raw.params == data) + } + } + } + + @Test func mcpElicitationRejectsInvalidTaggedPayloads() throws { + #expect(throws: DecodingError.self) { + try decode( + "mcpServer/elicitation/request", + #"{"threadId":"thread-1","turnId":null,"serverName":"server","mode":"url","_meta":null,"message":"Open"}"# + ) + } + #expect(throws: DecodingError.self) { + try decode( + "mcpServer/elicitation/request", + #"{"threadId":"thread-1","turnId":null,"serverName":"server","mode":"future","_meta":null,"message":"Unknown"}"# + ) + } + } + + @Test func builtInPolicyProducesExactCurrentV2Responses() throws { + let clock = CodexAppServerClock { + Date(timeIntervalSince1970: 1_700_000_000.75) + } + let cases: [(CodexAppServerRequest, String)] = [ + ( + try decode( + "item/commandExecution/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1}"# + ), + #"{"decision":"decline"}"# + ), + ( + try decode( + "item/fileChange/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1,"reason":null,"grantRoot":null}"# + ), + #"{"decision":"decline"}"# + ), + ( + try decode( + "item/tool/requestUserInput", + #"{"threadId":"t","turnId":"u","itemId":"i","questions":[],"autoResolutionMs":null}"# + ), + #"{"answers":{}}"# + ), + ( + try decode( + "mcpServer/elicitation/request", + #"{"threadId":"t","turnId":null,"serverName":"s","mode":"url","_meta":null,"message":"m","requestedSchema":null,"url":"https://example.com","elicitationId":"e"}"# + ), + #"{"action":"cancel","content":null,"_meta":null}"# + ), + ( + try decode( + "item/permissions/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","environmentId":null,"startedAtMs":1,"cwd":"/tmp","reason":null,"permissions":{}}"# + ), + #"{"permissions":{},"scope":"turn","strictAutoReview":false}"# + ), + ( + try decode( + "item/tool/call", + #"{"threadId":"t","turnId":"u","callId":"c","namespace":null,"tool":"x","arguments":{}}"# + ), + #"{"contentItems":[{"type":"inputText","text":"Dynamic tool calls are not supported by this client."}],"success":false}"# + ), + ( + try decode("currentTime/read", #"{"threadId":"t"}"#), + #"{"currentTimeAt":1700000000}"# + ), + ] + + for (request, expectedJSON) in cases { + let resolution = CodexAppServerRequestCodec.builtInResolution( + for: request, + clock: clock + ) + let response = codec.response(to: request, resolution: resolution) + let data = try resultData(from: response) + #expect(try jsonEqual(data, Data(expectedJSON.utf8))) + } + } + + @Test func builtInPolicyRejectsProviderlessAndUnknownMethods() throws { + let clock = CodexAppServerClock(now: { .distantPast }) + let requests = [ + ( + try decode( + "account/chatgptAuthTokens/refresh", + #"{"reason":"unauthorized","previousAccountId":null}"# + ), + "No client provider is configured for account/chatgptAuthTokens/refresh." + ), + ( + try decode("attestation/generate", #"{}"#), + "No client provider is configured for attestation/generate." + ), + ( + try decode("future/method", #"{}"#), + "Method not found: future/method" + ), + ] + + for (request, expectedMessage) in requests { + let resolution = CodexAppServerRequestCodec.builtInResolution( + for: request, + clock: clock + ) + guard case .error(let code, let message) = codec.response( + to: request, + resolution: resolution + ) else { + Issue.record("Expected method-not-found for \(request.method).") + continue + } + #expect(code == -32601) + #expect(message == expectedMessage) + + let payload = try AppServerProcessTransport.serverRequestResponsePayload( + id: .string("request"), + response: .error(code: code, message: message) + ) + let expected = Data( + #"{"id":"request","error":{"code":-32601,"message":"\#(expectedMessage)"}}"#.utf8 + ) + #expect(try jsonEqual(payload, expected)) + } + } + + @Test func mismatchHandlerFailureAndEncodeFailureAreInternalErrors() async throws { + let request = try decode("currentTime/read", #"{"threadId":"t"}"#) + guard case .error(let mismatchCode, _) = codec.response( + to: request, + resolution: .approval(.accept) + ) else { + Issue.record("Expected an internal error for a resolution mismatch.") + return + } + #expect(mismatchCode == -32603) + + let handled = await codec.handle(request) { _ in + throw HandlerFailure() + } + guard case .error(let handlerCode, _) = handled else { + Issue.record("Expected an internal error for a thrown handler error.") + return + } + #expect(handlerCode == -32603) + + let permissions = try decode( + "item/permissions/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","environmentId":null,"startedAtMs":1,"cwd":"/tmp","reason":null,"permissions":{}}"# + ) + let encodeFailure = codec.response( + to: permissions, + resolution: .permissions(.init( + permissions: .init(network: .double(.infinity), fileSystem: nil), + scope: .turn, + strictAutoReview: false + )) + ) + guard case .error(let encodeCode, _) = encodeFailure else { + Issue.record("Expected an internal error for response encoding failure.") + return + } + #expect(encodeCode == -32603) + } + + @Test func methodSpecificResponsesMatchCurrentV2TaggedAndNullShapes() throws { + let command = try decode( + "item/commandExecution/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1}"# + ) + let execPolicyResponse = codec.response( + to: command, + resolution: .approval(.acceptWithExecpolicyAmendment( + .init(command: ["git", "status"]) + )) + ) + #expect(try jsonEqual( + resultData(from: execPolicyResponse), + Data( + #"{"decision":{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","status"]}}}"#.utf8 + ) + )) + + let networkPolicyResponse = codec.response( + to: command, + resolution: .approval(.applyNetworkPolicyAmendment(.init( + host: "example.com", + action: .allow + ))) + ) + #expect(try jsonEqual( + resultData(from: networkPolicyResponse), + Data( + #"{"decision":{"applyNetworkPolicyAmendment":{"network_policy_amendment":{"host":"example.com","action":"allow"}}}}"#.utf8 + ) + )) + + let file = try decode( + "item/fileChange/requestApproval", + #"{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1}"# + ) + guard case .error(let fileMismatchCode, _) = codec.response( + to: file, + resolution: .approval(.acceptWithExecpolicyAmendment(.init(command: ["git"]))) + ) else { + Issue.record("Expected command-only approval to be rejected for file changes.") + return + } + #expect(fileMismatchCode == -32603) + + let refresh = try decode( + "account/chatgptAuthTokens/refresh", + #"{"reason":"unauthorized","previousAccountId":null}"# + ) + let refreshResponse = codec.response( + to: refresh, + resolution: .chatGPTAuthTokensRefresh(.init( + accessToken: "access", + chatGPTAccountID: "account", + chatGPTPlanType: nil + )) + ) + #expect(try jsonEqual( + resultData(from: refreshResponse), + Data( + #"{"accessToken":"access","chatgptAccountId":"account","chatgptPlanType":null}"#.utf8 + ) + )) + } + + private func decode(_ method: String, _ json: String) throws -> CodexAppServerRequest { + try codec.decode(method: method, params: Data(json.utf8)) + } + + private func resultData(from response: CodexServerRequestResponse) throws -> Data { + guard case .result(let data) = response else { + throw UnexpectedResponse() + } + return data + } + + private func jsonEqual(_ lhs: Data, _ rhs: Data) throws -> Bool { + let left = try JSONSerialization.jsonObject(with: lhs, options: [.fragmentsAllowed]) + let right = try JSONSerialization.jsonObject(with: rhs, options: [.fragmentsAllowed]) + return (left as AnyObject).isEqual(right) + } + + private func kind(of request: CodexAppServerRequest) -> RequestKind { + switch request { + case .commandExecutionApproval: .commandApproval + case .fileChangeApproval: .fileApproval + case .userInput: .userInput + case .mcpElicitation: .mcpElicitation + case .permissions: .permissions + case .dynamicToolCall: .dynamicToolCall + case .chatGPTAuthTokensRefresh: .tokenRefresh + case .attestationGenerate: .attestation + case .currentTimeRead: .currentTime + case .unknown: .unknown + } + } + + private enum RequestKind: Equatable { + case commandApproval + case fileApproval + case userInput + case mcpElicitation + case permissions + case dynamicToolCall + case tokenRefresh + case attestation + case currentTime + case unknown + } + + private struct HandlerFailure: Error {} + private struct UnexpectedResponse: Error {} +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerTestNotificationEmitterTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerTestNotificationEmitterTests.swift new file mode 100644 index 00000000..c0273fd8 --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexAppServerTestNotificationEmitterTests.swift @@ -0,0 +1,24 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import Testing + +@Suite("CodexAppServerTestNotificationEmitter") +struct CodexAppServerTestNotificationEmitterTests { + @Test(arguments: ["", " \n\t "]) + func agentMessageDeltaRejectsInvalidItemIdentity(_ itemID: String) async throws { + let runtime = try await CodexAppServerTestRuntime.start() + + await #expect(throws: CodexAppServerTestError.invalidFixture( + "thread, turn, and item ids must not be empty or whitespace" + )) { + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: itemID, + delta: "invalid" + ) + } + + await runtime.close() + } +} diff --git a/Tests/CodexAppServerKitTests/CodexAppServerTestStoredThreadTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerTestStoredThreadTests.swift new file mode 100644 index 00000000..e65188a5 --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexAppServerTestStoredThreadTests.swift @@ -0,0 +1,976 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit +@testable import CodexAppServerKitTesting + +@Suite("CodexAppServerTestStoredThread") +struct CodexAppServerTestStoredThreadTests { + @Test func userMessageOwnsCanonicalCurrentV2Content() throws { + let item = try CodexAppServerTestItem.userMessage( + id: "user-message-1", + text: "Review this change" + ) + + #expect(item.domainProjection.kind == .userMessage) + #expect(item.domainProjection.text == "Review this change") + #expect(item.wireValue == .object([ + "id": .string("user-message-1"), + "type": .string("userMessage"), + "content": .array([ + .object([ + "type": .string("text"), + "text": .string("Review this change"), + "textElements": .array([]), + ]) + ]), + ])) + } + + @Test func canonicalWireValuesComeOnlyFromValidatedOpaqueFixtures() throws { + let fixture = try makeStoredThreadFixture(historyMode: .paginated) + + guard case .object(let thread) = fixture.stored.wireValue else { + Issue.record("Expected a canonical stored-thread wire object.") + return + } + #expect(thread["id"] == .string("thread-1")) + #expect(thread["sessionId"] == .string("session-1")) + #expect(thread["forkedFromId"] == .null) + #expect(thread["parentThreadId"] == .null) + #expect(thread["historyMode"] == .string("paginated")) + #expect(thread["source"] == .string("appServer")) + #expect(thread["cwd"] == .string(fixture.workspace.path)) + #expect(thread["turns"] == .array([fixture.turn.wireValue])) + #expect(thread["path"] == .null) + #expect(thread["threadSource"] == .null) + + guard case .object(let runtime) = fixture.runtimeMetadata.wireValue else { + Issue.record("Expected canonical runtime metadata wire fields.") + return + } + #expect(runtime["model"] == .string("gpt-5-codex")) + #expect(runtime["modelProvider"] == .string("openai")) + #expect(runtime["cwd"] == .string(fixture.workspace.path)) + #expect( + runtime["runtimeWorkspaceRoots"] + == .array([ + .string(fixture.workspace.path) + ])) + #expect( + runtime["instructionSources"] + == .array([ + .string(fixture.instructions.path) + ])) + #expect( + runtime["approvalPolicy"] + == .object([ + "granular": .object([ + "sandbox_approval": .bool(true), + "rules": .bool(false), + "skill_approval": .bool(true), + "request_permissions": .bool(false), + "mcp_elicitations": .bool(true), + ]) + ])) + #expect(runtime["approvalsReviewer"] == .string("auto_review")) + #expect( + runtime["activePermissionProfile"] + == .object([ + "id": .string("workspace"), + "extends": .string("base"), + ])) + #expect(runtime["reasoningEffort"] == .string("high")) + #expect( + runtime["multiAgentMode"] + == .object([ + "custom": .string("delegate focused work") + ])) + + let threadPage = CodexAppServerTestThreadPage( + threads: [fixture.stored], + nextCursor: "thread-next" + ) + guard case .object(let threadPageWire) = threadPage.wireValue, + case .array(let listedValues)? = threadPageWire["data"], + listedValues.count == 1, + case .object(let listedThread) = listedValues[0] + else { + Issue.record("Expected one canonical listed thread.") + return + } + #expect(listedThread["turns"] == .array([])) + #expect(threadPageWire["nextCursor"] == .string("thread-next")) + #expect(threadPageWire["backwardsCursor"] == .null) + + let turnPage = CodexAppServerTestTurnPage( + turns: [fixture.turn], + backwardsCursor: "turn-previous" + ) + #expect( + turnPage.wireValue + == .object([ + "data": .array([fixture.turn.wireValue]), + "nextCursor": .null, + "backwardsCursor": .string("turn-previous"), + ])) + } + + @Test func canonicalSessionSourceProjectsSubAgentReview() throws { + let stored = try makeRuntimeStoredThreadFixture( + id: "thread-review", + source: .subAgentReview + ) + + guard case .object(let wire) = stored.wireValue else { + Issue.record("Expected a canonical stored-thread wire object.") + return + } + #expect(wire["source"] == .object(["subAgent": .string("review")])) + #expect(stored.snapshot.sourceKind == .subAgentReview) + } + + @Test func threadStoreListUsesProductionSourceFilterSemantics() async throws { + let runtime = try await CodexAppServerTestRuntime.start(threads: [ + makeRuntimeStoredThreadFixture(id: "cli", source: .cli), + makeRuntimeStoredThreadFixture(id: "vscode", source: .vscode), + makeRuntimeStoredThreadFixture(id: "atlas", source: .custom("atlas")), + makeRuntimeStoredThreadFixture(id: "chatgpt", source: .custom("chatgpt")), + makeRuntimeStoredThreadFixture(id: "custom", source: .custom("other")), + makeRuntimeStoredThreadFixture(id: "exec", source: .exec), + makeRuntimeStoredThreadFixture(id: "app-server", source: .appServer), + makeRuntimeStoredThreadFixture(id: "review", source: .subAgentReview), + makeRuntimeStoredThreadFixture(id: "compact", source: .subAgentCompact), + ]) + + let interactiveIDs = ["cli", "vscode", "atlas", "chatgpt"] + #expect(try await runtime.server.listThreads().threads.map(\.id.rawValue) == interactiveIDs) + #expect(try await runtime.server.listThreads(.init( + sourceKinds: [] + )).threads.map(\.id.rawValue) == interactiveIDs) + #expect(try await runtime.server.listThreads(.init( + sourceKinds: [.appServer] + )).threads.map(\.id.rawValue) == ["app-server"]) + #expect(try await runtime.server.listThreads(.init( + sourceKinds: [.subAgentReview] + )).threads.map(\.id.rawValue) == ["review"]) + #expect(try await runtime.server.listThreads(.init( + sourceKinds: [.subAgent] + )).threads.map(\.id.rawValue) == ["review", "compact"]) + + await runtime.close() + } + + @Test func threadStoreRecencySortUsesThreadIDTieBreakerAcrossPages() async throws { + let recencyAt = Date(timeIntervalSince1970: 100) + let firstID = CodexThreadID("00000000-0000-0000-0000-000000000001") + let secondID = CodexThreadID("00000000-0000-0000-0000-000000000002") + let thirdID = CodexThreadID("00000000-0000-0000-0000-000000000003") + let runtime = try await CodexAppServerTestRuntime.start(threads: [ + makeRuntimeStoredThreadFixture(id: secondID, recencyAt: recencyAt), + makeRuntimeStoredThreadFixture(id: firstID, recencyAt: recencyAt), + makeRuntimeStoredThreadFixture(id: thirdID, recencyAt: recencyAt), + ]) + + for (direction, expectedIDs) in [ + (CodexSortDirection.ascending, [firstID, secondID, thirdID]), + (CodexSortDirection.descending, [thirdID, secondID, firstID]), + ] { + let firstPage = try await runtime.server.listThreads(.init( + limit: 2, + sortDirection: direction, + sortKey: .recencyAt + )) + #expect(firstPage.threads.map(\.id) == Array(expectedIDs.prefix(2))) + let nextCursor = try #require(firstPage.nextCursor) + + let secondPage = try await runtime.server.listThreads(.init( + cursor: nextCursor, + limit: 2, + sortDirection: direction, + sortKey: .recencyAt + )) + #expect(secondPage.threads.map(\.id) == Array(expectedIDs.dropFirst(2))) + #expect(secondPage.nextCursor == nil) + #expect(secondPage.backwardsCursor != nil) + } + + await runtime.close() + } + + @Test func threadStoreListClampsProductionPageSize() async throws { + let runtime = try await CodexAppServerTestRuntime.start(threads: (0..<102).map { index in + try makeRuntimeStoredThreadFixture(id: CodexThreadID(rawValue: "thread-\(index)")) + }) + + let defaultPage = try await runtime.server.listThreads() + #expect(defaultPage.threads.count == 25) + #expect(defaultPage.nextCursor != nil) + + let minimumPage = try await runtime.server.listThreads(.init(limit: 0)) + #expect(minimumPage.threads.count == 1) + #expect(minimumPage.nextCursor != nil) + + let maximumPage = try await runtime.server.listThreads(.init(limit: 101)) + #expect(maximumPage.threads.count == 100) + let maximumNextCursor = try #require(maximumPage.nextCursor) + + let maximumSecondPage = try await runtime.server.listThreads(.init( + cursor: maximumNextCursor, + limit: 101 + )) + #expect(maximumSecondPage.threads.count == 2) + #expect(maximumSecondPage.nextCursor == nil) + + await runtime.close() + } + + @Test func replacingTurnsRevalidatesProjectionAndPreservesHiddenMetadata() throws { + let fixture = try makeStoredThreadFixture() + let replacementItem = try CodexAppServerTestItem.plan( + id: "plan-2", + text: "Updated plan" + ) + let replacementTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-2", + state: .completed, + itemsLoadState: .summary, + items: [replacementItem.domainProjection] + ), + items: [replacementItem] + ) + + let replacement = try fixture.stored.replacingTurns([replacementTurn]) + + #expect(fixture.stored.turns == [fixture.turn]) + #expect(replacement.turns == [replacementTurn]) + #expect(replacement.snapshot.turns == [replacementTurn.snapshot]) + #expect(replacement.snapshot.turnItemsAreAuthoritative == false) + #expect(replacement.metadata == fixture.stored.metadata) + #expect(replacement.runtimeMetadata == fixture.stored.runtimeMetadata) + #expect(replacement.isArchived == fixture.stored.isArchived) + guard case .object(let wire) = replacement.wireValue else { + Issue.record("Expected a replacement stored-thread wire object.") + return + } + #expect(wire["turns"] == .array([replacementTurn.wireValue])) + } + + @Test func runtimeMetadataRejectsInvalidRequiredValuesAndFilesystemURLs() throws { + let workspace = URL(fileURLWithPath: "/tmp/codex-kit-fixture", isDirectory: true) + let webURL = try #require(URL(string: "https://example.com/workspace")) + let relativeFileURL = try #require(URL(string: "file:relative")) + + #expect(throws: CodexAppServerTestError.invalidFixture("model must not be empty")) { + _ = try makeRuntimeMetadata(model: " ", cwd: workspace) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "model provider must not be empty" + ) + ) { + _ = try makeRuntimeMetadata(modelProvider: "\n", cwd: workspace) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "cwd must be an absolute file URL" + ) + ) { + _ = try makeRuntimeMetadata(cwd: webURL) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "runtime workspace root must be an absolute file URL" + ) + ) { + _ = try makeRuntimeMetadata( + cwd: workspace, + runtimeWorkspaceRoots: [relativeFileURL] + ) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "instruction source must be an absolute file URL" + ) + ) { + _ = try makeRuntimeMetadata(cwd: workspace, instructionSources: [webURL]) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "sandbox writable root must be an absolute file URL" + ) + ) { + _ = try makeRuntimeMetadata( + cwd: workspace, + sandbox: .workspaceWrite( + writableRoots: [webURL], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ) + ) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "custom multi-agent mode must not be empty" + ) + ) { + _ = try makeRuntimeMetadata(cwd: workspace, multiAgentMode: .custom(" ")) + } + #expect( + throws: CodexAppServerTestError.invalidFixture( + "active permission profile id must not be empty" + ) + ) { + _ = try CodexAppServerTestThreadRuntimeMetadata.ActivePermissionProfile(id: " ") + } + } + + @Test func storedThreadRejectsMissingAndInconsistentProjections() throws { + let fixture = try makeStoredThreadFixture() + + var missingPreview = fixture.snapshot + missingPreview.preview = nil + #expect(throws: CodexAppServerTestError.invalidFixture("thread preview is required")) { + _ = try CodexAppServerTestStoredThread( + snapshot: missingPreview, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var mismatchedTurns = fixture.snapshot + mismatchedTurns.turns = [] + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread snapshot turns must match the Testing turn projections" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: mismatchedTurns, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var mismatchedSource = fixture.snapshot + mismatchedSource.sourceKind = .cli + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread snapshot source must match the Testing thread metadata" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: mismatchedSource, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var explicitNullSession = fixture.snapshot + explicitNullSession.sessionID = nil + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread snapshot session id must match the Testing thread metadata" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: explicitNullSession, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var explicitNullSource = fixture.snapshot + explicitNullSource.source = nil + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread snapshot source must match the Testing thread metadata" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: explicitNullSource, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var spawnMetadata = fixture.metadata + spawnMetadata.source = .subAgentThreadSpawn( + parentThreadID: "source-parent", + depth: 1, + agentPath: nil, + agentNickname: nil, + agentRole: nil + ) + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread-spawn source parent must match the Testing thread metadata parent" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fixture.snapshot, + turns: [fixture.turn], + metadata: spawnMetadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var gitMetadata = fixture.metadata + gitMetadata.gitInfo = .init(sha: "abc123") + var gitSnapshot = fixture.snapshot + gitSnapshot.presentFields.remove(.gitInfo) + let gitFixture = try CodexAppServerTestStoredThread( + snapshot: gitSnapshot, + turns: [fixture.turn], + metadata: gitMetadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + var explicitNullGitInfo = gitFixture.snapshot + explicitNullGitInfo.gitInfo = nil + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread snapshot Git metadata must match the Testing thread metadata" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: explicitNullGitInfo, + turns: [fixture.turn], + metadata: gitFixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var fractionalCreationTime = fixture.snapshot + fractionalCreationTime.createdAt = Date(timeIntervalSince1970: 10.5) + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread creation time must be representable as whole Unix seconds" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fractionalCreationTime, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var unsupportedActiveFlag = fixture.snapshot + unsupportedActiveFlag.status = .active(activeFlags: ["futureFlag"]) + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread active flags must use pinned current-v2 values" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: unsupportedActiveFlag, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var emptyMetadata = fixture.metadata + emptyMetadata.sessionID = "\t" + #expect( + throws: CodexAppServerTestError.invalidFixture( + "thread session id must not be empty" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fixture.snapshot, + turns: [fixture.turn], + metadata: emptyMetadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + var unsupportedMetadata = fixture.metadata + unsupportedMetadata.source = .custom(" ") + #expect( + throws: CodexAppServerTestError.invalidFixture( + "custom thread session source must not be empty" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fixture.snapshot, + turns: [fixture.turn], + metadata: unsupportedMetadata, + runtimeMetadata: fixture.runtimeMetadata, + isArchived: false + ) + } + + let wrongProvider = try makeRuntimeMetadata( + modelProvider: "other", + cwd: fixture.workspace + ) + #expect( + throws: CodexAppServerTestError.invalidFixture( + "runtime model provider must match the thread snapshot" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fixture.snapshot, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: wrongProvider, + isArchived: false + ) + } + + let wrongCWD = try makeRuntimeMetadata( + cwd: URL(fileURLWithPath: "/tmp/other-workspace", isDirectory: true) + ) + #expect( + throws: CodexAppServerTestError.invalidFixture( + "runtime cwd must match the thread snapshot" + ) + ) { + _ = try CodexAppServerTestStoredThread( + snapshot: fixture.snapshot, + turns: [fixture.turn], + metadata: fixture.metadata, + runtimeMetadata: wrongCWD, + isArchived: false + ) + } + } +} + +private struct CodexAppServerStoredThreadFixture { + var workspace: URL + var instructions: URL + var turn: CodexAppServerTestTurn + var snapshot: CodexThreadSnapshot + var metadata: CodexAppServerTestThreadMetadata + var runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata + var stored: CodexAppServerTestStoredThread +} + +private func makeStoredThreadFixture( + historyMode: CodexAppServerTestThreadMetadata.HistoryMode = .legacy +) throws -> CodexAppServerStoredThreadFixture { + let workspace = URL(fileURLWithPath: "/tmp/codex-kit-fixture", isDirectory: true) + let instructions = workspace.appending(path: "AGENTS.md", directoryHint: .notDirectory) + let item = try CodexAppServerTestItem.agentMessage(id: "message-1", text: "Done") + let turn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-1", + state: .completed, + items: [item.domainProjection] + ), + items: [item] + ) + let snapshot = CodexThreadSnapshot( + id: "thread-1", + workspace: workspace, + preview: "Inspect the change", + modelProvider: "openai", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20), + status: .idle, + ephemeral: false, + turns: [turn.snapshot] + ) + let metadata = CodexAppServerTestThreadMetadata( + sessionID: "session-1", + cliVersion: "codex-cli-test", + source: .appServer, + historyMode: historyMode + ) + let runtimeMetadata = try makeRuntimeMetadata( + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [instructions], + approvalPolicy: .granular( + sandboxApproval: true, + rules: false, + skillApproval: true, + requestPermissions: false, + mcpElicitations: true + ), + approvalsReviewer: .autoReview, + sandbox: .workspaceWrite( + writableRoots: [workspace], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: false + ), + activePermissionProfile: .init(id: "workspace", extends: "base"), + reasoningEffort: .high, + multiAgentMode: .custom("delegate focused work") + ) + let stored = try CodexAppServerTestStoredThread( + snapshot: snapshot, + turns: [turn], + metadata: metadata, + runtimeMetadata: runtimeMetadata, + isArchived: true + ) + return .init( + workspace: workspace, + instructions: instructions, + turn: turn, + snapshot: snapshot, + metadata: metadata, + runtimeMetadata: runtimeMetadata, + stored: stored + ) +} + +private func makeRuntimeMetadata( + model: String = "gpt-5-codex", + modelProvider: String = "openai", + cwd: URL, + runtimeWorkspaceRoots: [URL] = [], + instructionSources: [URL] = [], + approvalPolicy: CodexAppServerTestThreadRuntimeMetadata.ApprovalPolicy = .never, + approvalsReviewer: CodexAppServerTestThreadRuntimeMetadata.ApprovalsReviewer = .user, + sandbox: CodexAppServerTestThreadRuntimeMetadata.SandboxPolicy = .dangerFullAccess, + activePermissionProfile: CodexAppServerTestThreadRuntimeMetadata.ActivePermissionProfile? = nil, + reasoningEffort: CodexReasoningEffort? = nil, + multiAgentMode: CodexAppServerTestThreadRuntimeMetadata.MultiAgentMode = .explicitRequestOnly +) throws -> CodexAppServerTestThreadRuntimeMetadata { + try .init( + model: model, + modelProvider: modelProvider, + serviceTier: nil, + cwd: cwd, + runtimeWorkspaceRoots: runtimeWorkspaceRoots, + instructionSources: instructionSources, + approvalPolicy: approvalPolicy, + approvalsReviewer: approvalsReviewer, + sandbox: sandbox, + activePermissionProfile: activePermissionProfile, + reasoningEffort: reasoningEffort, + multiAgentMode: multiAgentMode + ) +} + +func makeRuntimeTestTurnFixture( + id: CodexTurnID, + state: CodexTurnSnapshot.State = .completed +) throws -> CodexAppServerTestTurn { + try .init( + snapshot: .init(id: id, state: state), + items: [] + ) +} + +func makeRuntimeStoredThreadFixture( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + model: String = "gpt-5", + modelProvider: String = "openai", + source: CodexAppServerTestSessionSource = .cli, + createdAt: Date = Date(timeIntervalSince1970: 10), + updatedAt: Date = Date(timeIntervalSince1970: 20), + recencyAt: Date? = nil, + status: CodexThreadStatus = .idle, + ephemeral: Bool = false, + turns: [CodexAppServerTestTurn] = [], + isArchived: Bool = false, + forkedFromID: CodexThreadID? = nil +) throws -> CodexAppServerTestStoredThread { + let workspace = workspace + ?? URL(fileURLWithPath: "/tmp/\(id.rawValue)", isDirectory: true) + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + name: name, + preview: preview ?? id.rawValue, + modelProvider: modelProvider, + sourceKind: source.sourceKind, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "session-\(id.rawValue)", + forkedFromID: forkedFromID, + cliVersion: "codex-cli-test", + source: source + ), + runtimeMetadata: .init( + model: model, + modelProvider: modelProvider, + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: isArchived + ) +} + +struct AppServerKitTestThreadFixture { + var id: CodexThreadID + var workspace: URL? + var name: String? + var preview: String? + var modelProvider: String? + var sourceKind: CodexThreadSourceKind? + var createdAt: Date? + var updatedAt: Date? + var recencyAt: Date? + var status: CodexThreadStatus? + var ephemeral: Bool? + var turns: [AppServerKitTestTurnFixture]? + + init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + sourceKind: CodexThreadSourceKind? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + recencyAt: Date? = nil, + status: CodexThreadStatus? = nil, + ephemeral: Bool? = nil, + turns: [AppServerKitTestTurnFixture]? = nil + ) { + self.id = id + self.workspace = workspace + self.name = name + self.preview = preview + self.modelProvider = modelProvider + self.sourceKind = sourceKind + self.createdAt = createdAt + self.updatedAt = updatedAt + self.recencyAt = recencyAt + self.status = status + self.ephemeral = ephemeral + self.turns = turns + } + + func storedThread(model: String? = nil) throws -> CodexAppServerTestStoredThread { + let source = sourceKind?.testSessionSource ?? .appServer + return try makeRuntimeStoredThreadFixture( + id: id, + workspace: workspace, + name: name, + preview: preview, + model: model ?? "gpt-5", + modelProvider: modelProvider ?? "openai", + source: source, + createdAt: createdAt ?? Date(timeIntervalSince1970: 10), + updatedAt: updatedAt ?? createdAt ?? Date(timeIntervalSince1970: 20), + recencyAt: recencyAt, + status: status ?? .idle, + ephemeral: ephemeral ?? false, + turns: try (turns ?? []).map { try $0.testTurn } + ) + } +} + +struct AppServerKitTestTurnFixture { + var id: CodexTurnID + var state: CodexTurnSnapshot.State + var itemsLoadState: CodexTurnItemsLoadState + var items: [CodexThreadItem] + var startedAt: Date? + var completedAt: Date? + var duration: Duration? + + init( + id: CodexTurnID, + state: CodexTurnSnapshot.State, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexThreadItem] = [], + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) { + self.id = id + self.state = state + self.itemsLoadState = itemsLoadState + self.items = items + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration + } + + var testTurn: CodexAppServerTestTurn { + get throws { + let items = try items.map { try $0.testItem } + return try .init( + snapshot: .init( + id: id, + state: state, + itemsLoadState: itemsLoadState, + items: items.map(\.domainProjection), + startedAt: startedAt, + completedAt: completedAt, + duration: duration + ), + items: items + ) + } + } +} + +extension CodexAppServerTestTransport { + func enqueueThreadStart(threadID: String, model: String? = nil) throws { + try enqueueThreadStart( + AppServerKitTestThreadFixture(id: .init(rawValue: threadID)) + .storedThread(model: model) + ) + } + + func enqueueThreadResume( + _ thread: AppServerKitTestThreadFixture, + model: String? = nil + ) throws { + try enqueueThreadResume(try thread.storedThread(model: model)) + } + + func enqueueThreadRead(_ thread: AppServerKitTestThreadFixture) throws { + try enqueueThreadRead(try thread.storedThread()) + } + + func enqueueTurnStart( + turnID: String, + status: String = "inProgress" + ) throws { + try enqueueTurnStart(try AppServerKitTestTurnFixture( + id: .init(rawValue: turnID), + state: status.testTurnState + ).testTurn) + } + + func enqueueReviewStart( + turnID: String, + reviewThreadID: String, + status: CodexTurnStatus = .inProgress, + items: [CodexThreadItem] = [] + ) throws { + try enqueueReviewStart( + try AppServerKitTestTurnFixture( + id: .init(rawValue: turnID), + state: status.testTurnState, + items: items + ).testTurn, + reviewThreadID: .init(rawValue: reviewThreadID) + ) + } + + func enqueueReviewStart( + _ turn: AppServerKitTestTurnFixture, + reviewThreadID: String + ) throws { + try enqueueReviewStart( + try turn.testTurn, + reviewThreadID: .init(rawValue: reviewThreadID) + ) + } +} + +private extension CodexThreadSourceKind { + var testSessionSource: CodexAppServerTestSessionSource { + switch self { + case .cli: .cli + case .vscode: .vscode + case .exec: .exec + case .appServer: .appServer + case .subAgentReview: .subAgentReview + case .subAgentCompact: .subAgentCompact + case .subAgentThreadSpawn: + .subAgentThreadSpawn( + parentThreadID: "app-server-kit-testing-parent", + depth: 0, + agentPath: nil, + agentNickname: nil, + agentRole: nil + ) + case .subAgentOther: .subAgentOther("app-server-kit-testing") + case .subAgent: .subAgentMemoryConsolidation + case .unknown: .unknown + default: .custom(rawValue) + } + } +} + +private extension String { + var testTurnState: CodexTurnSnapshot.State { + switch self { + case "inProgress", "running": .inProgress + case "completed": .completed + case "interrupted": .interrupted + default: .unknown(rawValue: self, error: nil) + } + } +} + +private extension CodexTurnStatus { + var testTurnState: CodexTurnSnapshot.State { + switch self { + case .inProgress: .inProgress + case .completed: .completed + case .interrupted: .interrupted + case .failed: .failed(.init(message: "Testing review failure")) + case .unknown(let rawValue): .unknown(rawValue: rawValue, error: nil) + } + } +} + +private extension CodexThreadItem { + var testItem: CodexAppServerTestItem { + get throws { + switch (kind, content) { + case (.userMessage, .message(let message)): + try .userMessage(id: id, text: message.text) + case (.agentMessage, .message(let message)): + try .agentMessage(id: id, text: message.text, phase: message.phase) + case (.plan, .plan(let text)): + try .plan(id: id, text: text) + case (.reasoning, .reasoning(let reasoning)): + try .reasoning(id: id, summary: reasoning.summary, content: reasoning.content) + case (.enteredReviewMode, .log(let review)): + try .enteredReviewMode(id: id, review: review) + case (.exitedReviewMode, .log(let review)): + try .exitedReviewMode(id: id, review: review) + case (.contextCompaction, .contextCompaction): + try .contextCompaction(id: id) + default: + throw CodexAppServerTestError.invalidFixture( + "Unsupported AppServerKit current-v2 test item \(kind.rawValue)." + ) + } + } + } +} diff --git a/Tests/CodexAppServerKitTests/CodexItemReducerTests.swift b/Tests/CodexAppServerKitTests/CodexItemReducerTests.swift new file mode 100644 index 00000000..973c7e6a --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexItemReducerTests.swift @@ -0,0 +1,550 @@ +import Foundation +import Testing + +import CodexAppServerKitTesting +@testable import CodexAppServerKit + +@Suite("CodexItemReducer") +struct CodexItemReducerTests { + @Test func completionPreservesSemanticMetadataFromStartedItem() throws { + var reducer = CodexItemReducer() + let started = CodexThreadItem( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "partial")), + origin: .reviewRolloutAssistant, + semanticRelation: .companionOf(.exitedReviewMode) + ) + + _ = try reducer.apply(.started(started), turnID: "turn-1") + let completed = try reducer.apply( + .completed(.init( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "done")) + )), + turnID: "turn-1" + ) + + #expect(completed.origin == .reviewRolloutAssistant) + #expect(completed.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test func internalCurrentItemProjectionDoesNotChangeDeltaEquality() { + let item = CodexThreadItem( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "Hello")) + ) + #expect(CodexMessageDelta( + text: "Hello", + itemID: "message-1", + phase: .finalAnswer + ) == CodexMessageDelta( + text: "Hello", + itemID: "message-1", + phase: .finalAnswer, + currentItem: item + )) + + let publicPart = CodexReasoningPart(itemID: "reasoning-1", kind: .summary, index: 0) + let reducedPart = CodexReasoningPart( + itemID: "reasoning-1", + kind: .summary, + index: 0, + currentItem: .init( + id: "reasoning-1", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking")) + ) + ) + #expect(publicPart == reducedPart) + #expect(CodexReasoningDelta(part: publicPart, delta: "Checking") + == CodexReasoningDelta( + part: reducedPart, + delta: "Checking", + currentItem: item + )) + } + + @Test func turnDiagnosticsReduceToTypedNonterminalEventsWithoutCreatingItems() throws { + var reducer = CodexItemReducer() + let diagnostic = CodexTurnDiagnostic( + error: .init(message: "retrying", info: .serverOverloaded), + willRetry: true + ) + + #expect(try reducer.reduce( + .turnDiagnostic(diagnostic), + turnID: "turn-1" + ) == .diagnostic(diagnostic)) + #expect(reducer.item(turnID: "turn-1", itemID: "diagnostic") == nil) + } + + @Test func commandDeltasAppendAndCompletionPreservesStartedMetadata() throws { + var reducer = CodexItemReducer() + let startedAt = Date(timeIntervalSince1970: 100) + let completedAt = Date(timeIntervalSince1970: 101) + let action = CodexCommand.Action( + kind: .read, + command: "cat Sources/File.swift", + name: "File.swift", + path: "Sources/File.swift" + ) + let started = CodexThreadItem( + id: "command-1", + kind: .commandExecution, + content: .command(.init( + command: "swift test", + cwd: "/workspace", + status: .inProgress, + startedAt: startedAt, + processID: "process-1", + source: .agent, + commandActions: [action] + )), + rawPayload: Data("started".utf8) + ) + + _ = try reducer.apply(.started(started), turnID: "turn-1") + _ = try reducer.apply( + .commandOutputDelta(itemID: "command-1", delta: "first\n"), + turnID: "turn-1" + ) + _ = try reducer.apply( + .commandOutputDelta(itemID: "command-1", delta: "second\n"), + turnID: "turn-1" + ) + let completed = try reducer.apply( + .completed(.init( + id: "command-1", + kind: .commandExecution, + content: .command(.init( + command: "", + status: .completed, + completedAt: completedAt + )) + )), + turnID: "turn-1" + ) + + guard case .command(let command) = completed.content else { + Issue.record("Expected command item.") + return + } + #expect(command.command == "swift test") + #expect(command.cwd == "/workspace") + #expect(command.output == "first\nsecond\n") + #expect(command.status == .completed) + #expect(command.startedAt == startedAt) + #expect(command.completedAt == completedAt) + #expect(command.processID == "process-1") + #expect(command.source == .agent) + #expect(command.commandActions == [action]) + #expect(completed.rawPayload == Data("started".utf8)) + } + + @Test func filePatchUsesLatestSnapshotAndPreservesFileMetadata() throws { + var reducer = CodexItemReducer() + _ = try reducer.apply( + .started(.init( + id: "file-1", + kind: .fileChange, + content: .fileChange(.init( + path: "Sources/File.swift", + output: "initial", + status: .inProgress + )) + )), + turnID: "turn-1" + ) + _ = try reducer.apply( + .filePatchSnapshot(itemID: "file-1", output: "first snapshot"), + turnID: "turn-1" + ) + let latest = try reducer.apply( + .filePatchSnapshot(itemID: "file-1", output: "replacement snapshot"), + turnID: "turn-1" + ) + + guard case .fileChange(let fileChange) = latest.content else { + Issue.record("Expected file change item.") + return + } + #expect(fileChange.path == "Sources/File.swift") + #expect(fileChange.output == "replacement snapshot") + #expect(fileChange.status == .inProgress) + } + + @Test func agentPlanReasoningAndMCPDeltasUpdateTheirBaseItems() throws { + var reducer = CodexItemReducer() + _ = try reducer.apply( + .started(.init( + id: "message-1", + kind: .agentMessage, + content: .message(.init( + id: "message-1", + role: .assistant, + phase: .finalAnswer, + text: "Hello" + )) + )), + turnID: "turn-1" + ) + let message = try reducer.apply( + .agentMessageDelta(itemID: "message-1", delta: " world"), + turnID: "turn-1" + ) + #expect(message.message?.text == "Hello world") + #expect(message.message?.phase == .finalAnswer) + + _ = try reducer.apply( + .started(.init(id: "plan-1", kind: .plan, content: .plan("Step"))), + turnID: "turn-1" + ) + let plan = try reducer.apply( + .planDelta(itemID: "plan-1", delta: " one"), + turnID: "turn-1" + ) + #expect(plan.content == .plan("Step one")) + + _ = try reducer.apply( + .started(.init( + id: "reasoning-1", + kind: .reasoning, + content: .reasoning(.empty) + )), + turnID: "turn-1" + ) + _ = try reducer.apply( + .reasoningSummaryPartAdded(itemID: "reasoning-1", index: 0), + turnID: "turn-1" + ) + _ = try reducer.apply( + .reasoningSummaryDelta(itemID: "reasoning-1", index: 0, delta: "Summary"), + turnID: "turn-1" + ) + let reasoning = try reducer.apply( + .reasoningTextDelta(itemID: "reasoning-1", index: 0, delta: "Trace"), + turnID: "turn-1" + ) + #expect(reasoning.content == .reasoning(.init( + summary: ["Summary"], + content: ["Trace"] + ))) + + _ = try reducer.apply( + .started(.init( + id: "mcp-1", + kind: .mcpToolCall, + content: .toolCall(.init( + server: "docs", + name: "search", + arguments: "CodexItemReducer", + status: .inProgress + )) + )), + turnID: "turn-1" + ) + let mcp = try reducer.apply( + .mcpProgress(itemID: "mcp-1", message: "Reading"), + turnID: "turn-1" + ) + guard case .toolCall(let toolCall) = mcp.content else { + Issue.record("Expected MCP tool call item.") + return + } + #expect(toolCall.server == "docs") + #expect(toolCall.name == "search") + #expect(toolCall.arguments == "CodexItemReducer") + #expect(toolCall.result == "Reading") + #expect(toolCall.status == .inProgress) + } + + @Test func missingBaseAndMissingIDAreContractErrors() throws { + var reducer = CodexItemReducer() + do { + _ = try reducer.apply( + .commandOutputDelta(itemID: "command-1", delta: "output"), + turnID: "turn-1" + ) + Issue.record("Expected missing-base failure.") + } catch let error as CodexItemReducer.ContractError { + #expect(error == .missingBaseItem(turnID: "turn-1", itemID: "command-1")) + } + + do { + _ = try reducer.apply( + .started(.init( + id: "", + kind: .agentMessage, + content: .message(.init(id: "", role: .assistant, text: "")) + )), + turnID: "turn-1" + ) + Issue.record("Expected missing-ID failure.") + } catch let error as CodexItemReducer.ContractError { + #expect(error == .missingItemID) + } + } + + @Test func staleSnapshotSeedDoesNotOverwriteLiveReduction() throws { + var reducer = CodexItemReducer() + let liveItem = CodexThreadItem( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "live")) + ) + _ = try reducer.apply(.started(liveItem), turnID: "turn-1") + _ = try reducer.apply( + .agentMessageDelta(itemID: "message-1", delta: " update"), + turnID: "turn-1" + ) + + let missingSnapshotItem = CodexThreadItem( + id: "command-1", + kind: .commandExecution, + content: .command(.init(command: "swift test", status: .inProgress)) + ) + reducer.seed([.init( + id: "turn-1", + state: .inProgress, + items: [ + .init( + id: "message-1", + kind: .agentMessage, + content: .message(.init( + id: "message-1", + role: .assistant, + text: "stale" + )) + ), + missingSnapshotItem, + ] + )]) + + let updated = try reducer.apply( + .agentMessageDelta(itemID: "message-1", delta: " retained"), + turnID: "turn-1" + ) + #expect(updated.message?.text == "live update retained") + #expect(reducer.item(turnID: "turn-1", itemID: "command-1") == missingSnapshotItem) + } + + @Test func reducerReleasesTurnAndConnectionState() throws { + var reducer = CodexItemReducer() + let item = CodexThreadItem( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "")) + ) + _ = try reducer.apply(.started(item), turnID: "turn-1") + _ = try reducer.apply(.started(item), turnID: "turn-2") + + reducer.release(turnID: "turn-1") + #expect(reducer.item(turnID: "turn-1", itemID: "message-1") == nil) + #expect(reducer.item(turnID: "turn-2", itemID: "message-1") != nil) + + reducer.releaseAll() + #expect(reducer.item(turnID: "turn-2", itemID: "message-1") == nil) + } + + @Test func routerDoesNotMutateItemForThreadStatusAndReleasesOnTerminalAndStop() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let router = harness.router + await transport.waitForNotificationStreamCount(1) + + let firstState = await harness.turnReplayStore.restoreGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: harness.lease + ) + let firstTurnEvents = try await harness.turnReplayStore.events( + for: "turn-1", + state: firstState + ) + var firstTurnIterator = firstTurnEvents.makeAsyncIterator() + guard case .snapshot? = try await firstTurnIterator.next() else { + Issue.record("Expected the initial snapshot for turn-1.") + return + } + try await transport.emitServerNotification( + method: "item/started", + params: ItemLifecycleParams( + threadID: "thread-1", + turnID: "turn-1", + item: .init( + id: "command-1", + type: "commandExecution", + command: "swift test", + aggregatedOutput: "", + status: "inProgress" + ), + startedAtMS: 1_000 + ) + ) + guard case .itemStarted? = try await firstTurnIterator.next() else { + Issue.record("Expected the item/started event for turn-1.") + return + } + #expect(await router.itemSnapshotForTesting( + turnID: "turn-1", + itemID: "command-1" + ) != nil) + let beforeStatus = await router.itemSnapshotForTesting( + turnID: "turn-1", + itemID: "command-1" + ) + + let statusEvents = router.events(for: CodexThreadID(rawValue: "thread-1")) + var statusIterator = statusEvents.makeAsyncIterator() + try await transport.emitServerNotification( + method: "thread/status/changed", + params: ThreadStatusParams(threadID: "thread-1", status: .init(type: "idle")) + ) + var receivedStatus = false + while let event = try await statusIterator.next() { + if case .statusChanged = event { + receivedStatus = true + break + } + } + guard receivedStatus else { + Issue.record("Expected the thread/status/changed event.") + return + } + #expect(await router.itemSnapshotForTesting( + turnID: "turn-1", + itemID: "command-1" + ) == beforeStatus) + + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnTerminalParams(turn: .init(id: "turn-1", status: "completed")) + ) + var receivedTerminal = false + while let event = try await firstTurnIterator.next() { + if case .terminal = event { + receivedTerminal = true + break + } + } + guard receivedTerminal else { + Issue.record("Expected the terminal event for turn-1.") + return + } + #expect(try await firstTurnIterator.next() == nil) + #expect(await router.itemSnapshotForTesting( + turnID: "turn-1", + itemID: "command-1" + ) == nil) + + let secondState = await harness.turnReplayStore.restoreGeneration( + turnID: "turn-2", + initialSnapshot: .init(id: "turn-2", state: .inProgress), + connectionLease: harness.lease + ) + let secondTurnEvents = try await harness.turnReplayStore.events( + for: "turn-2", + state: secondState + ) + var secondTurnIterator = secondTurnEvents.makeAsyncIterator() + guard case .snapshot? = try await secondTurnIterator.next() else { + Issue.record("Expected the initial snapshot for turn-2.") + return + } + try await transport.emitServerNotification( + method: "item/started", + params: ItemLifecycleParams( + threadID: "thread-1", + turnID: "turn-2", + item: .init(id: "message-2", type: "agentMessage", text: ""), + startedAtMS: 2_000 + ) + ) + guard case .itemStarted? = try await secondTurnIterator.next() else { + Issue.record("Expected the item/started event for turn-2.") + return + } + #expect(await router.itemSnapshotForTesting( + turnID: "turn-2", + itemID: "message-2" + ) != nil) + await harness.close() + #expect(await router.itemSnapshotForTesting(turnID: "turn-2", itemID: "message-2") == nil) + } +} + +private struct ItemLifecycleParams: Encodable, Sendable { + struct Item: Encodable, Sendable { + var id: String + var type: String + var text: String? + var command: String? + var aggregatedOutput: String? + var status: String? + var cwd: String + var commandActions: [String] + + init( + id: String, + type: String, + text: String? = nil, + command: String? = nil, + aggregatedOutput: String? = nil, + status: String? = nil + ) { + self.id = id + self.type = type + self.text = text + self.command = command + self.aggregatedOutput = aggregatedOutput + self.status = status + self.cwd = "/workspace" + self.commandActions = [] + } + } + + var threadID: String + var turnID: String + var item: Item + var startedAtMS: Int64 + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case item + case startedAtMS = "startedAtMs" + } +} + +private struct ThreadStatusParams: Encodable, Sendable { + struct Status: Encodable, Sendable { + var type: String + } + + var threadID: String + var status: Status + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case status + } +} + +private struct TurnTerminalParams: Encodable, Sendable { + var threadID: String = "thread-1" + var turn: AppServerAPI.Turn.Payload + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + init(turn: AppServerAPI.Turn.Payload) { + var turn = turn + turn.items = turn.items ?? [] + self.turn = turn + } +} diff --git a/Tests/CodexAppServerKitTests/CodexTurnSnapshotReducerTests.swift b/Tests/CodexAppServerKitTests/CodexTurnSnapshotReducerTests.swift new file mode 100644 index 00000000..3ac531ee --- /dev/null +++ b/Tests/CodexAppServerKitTests/CodexTurnSnapshotReducerTests.swift @@ -0,0 +1,207 @@ +import Testing + +@testable import CodexAppServerKit + +@Suite("Turn snapshot reducer") +struct CodexTurnSnapshotReducerTests { + @Test func identityAloneDoesNotClaimTranscriptCompleteness() { + var reducer = CodexTurnSnapshotReducer(turnID: "turn-1") + + #expect(reducer.snapshot.itemsLoadState == .notLoaded) + + reducer.markStarted() + + #expect(reducer.snapshot.itemsLoadState == .full) + } + + @Test func delayedStartDoesNotPromoteSummarySnapshotCompleteness() { + let summary = item(id: "summary", kind: .agentMessage, text: "Summary") + let terminal = item(id: "terminal", kind: .agentMessage, text: "Terminal") + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [summary] + )) + + reducer.markStarted() + let compact = reducer.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminal]), + transcriptItemsLoadState: .summary + ))) + + #expect(compact.snapshot.itemsLoadState == .summary) + #expect(compact.snapshot.items == [summary, terminal]) + } + + @Test func delayedStartDoesNotPromoteNotLoadedHistorySnapshot() { + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + )) + + reducer.markStarted() + + #expect(reducer.snapshot.itemsLoadState == .notLoaded) + } + + @Test func delayedStartPromotesIdentityOnlyBindingSnapshot() { + var reducer = CodexTurnSnapshotReducer(turnID: "turn-1") + reducer.replaceBindingSnapshot(with: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + )) + + reducer.markStarted() + + #expect(reducer.snapshot.itemsLoadState == .full) + } + + @Test func sparseTerminalUpdatesAnUnobservedItemWithTheSameRawIDAsAnotherKind() { + let entered = item( + id: "review-marker", + kind: .enteredReviewMode, + text: "Entered" + ) + let staleExit = item( + id: "review-marker", + kind: .exitedReviewMode, + text: "Stale" + ) + let observedEntered = item( + id: "review-marker", + kind: .enteredReviewMode, + text: "Entered live" + ) + let terminalExit = item( + id: "review-marker", + kind: .exitedReviewMode, + text: "Final review" + ) + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [entered, staleExit] + )) + reducer.observe(observedEntered) + + let compact = reducer.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalExit]), + transcriptItemsLoadState: .summary + ))) + + #expect(compact.snapshot.items == [observedEntered, terminalExit]) + #expect(compact.outcome.response.transcript.items == [observedEntered, terminalExit]) + } + + @Test func sparseTerminalPreservesObservedOrderAndItems() { + let seeded = item(id: "seeded", kind: .agentMessage, text: "Seeded") + let observed = item(id: "observed", kind: .agentMessage, text: "Observed") + let terminal = item(id: "observed", kind: .agentMessage, text: "Summary") + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [seeded] + )) + reducer.observe(observed) + + let compact = reducer.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminal]), + transcriptItemsLoadState: .summary + ))) + + #expect(compact.snapshot.items == [seeded, observed]) + #expect(compact.outcome.response.transcript.items == [seeded, observed]) + } + + @Test func fullTerminalRemovesOmittedItems() { + let omitted = item(id: "omitted", kind: .agentMessage, text: "Omitted") + let retained = item(id: "retained", kind: .agentMessage, text: "Retained") + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [omitted] + )) + + let compact = reducer.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [retained]), + transcriptItemsLoadState: .full + ))) + + #expect(compact.snapshot.items == [retained]) + #expect(compact.outcome.response.transcript.items == [retained]) + } + + @Test func fullResponseSnapshotKeepsOnlyConcurrentObservedItems() { + let stale = item(id: "stale", kind: .agentMessage, text: "Stale") + let observed = item(id: "observed", kind: .agentMessage, text: "Observed") + let response = item(id: "response", kind: .agentMessage, text: "Response") + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [stale] + )) + reducer.observe(observed) + + reducer.merge(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [response] + )) + + #expect(reducer.snapshot.items == [response, observed]) + #expect(reducer.snapshot.itemsLoadState == .full) + } + + @Test func partialResponseSnapshotCannotReplaceACompleteItem() { + let first = item(id: "first", kind: .agentMessage, text: "First") + let complete = item(id: "message", kind: .agentMessage, text: "Complete") + let summary = item(id: "message", kind: .agentMessage, text: "Summary") + let newSummary = item(id: "new", kind: .agentMessage, text: "New summary") + var reducer = CodexTurnSnapshotReducer(snapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [first, complete] + )) + + reducer.merge(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [summary, newSummary] + )) + + #expect(reducer.snapshot.items == [first, complete, newSummary]) + #expect(reducer.snapshot.itemsLoadState == .full) + } + + private func item( + id: String, + kind: CodexThreadItem.Kind, + text: String + ) -> CodexThreadItem { + let content: CodexThreadItem.Content + switch kind { + case .agentMessage: + content = .message(.init(id: id, role: .assistant, text: text)) + case .enteredReviewMode, .exitedReviewMode: + content = .log(text) + default: + Issue.record("Unsupported test item kind \(kind).") + content = .log(text) + } + return .init(id: id, kind: kind, content: content) + } +} diff --git a/Tests/CodexAppServerKitTests/ConnectionEventHubTests.swift b/Tests/CodexAppServerKitTests/ConnectionEventHubTests.swift new file mode 100644 index 00000000..1c8b8119 --- /dev/null +++ b/Tests/CodexAppServerKitTests/ConnectionEventHubTests.swift @@ -0,0 +1,546 @@ +import Foundation +import Testing + +import CodexAppServerKitTesting +@testable import CodexAppServerKit + +@Suite("Connection termination arbitration") +struct ConnectionTerminationArbiterTests { + @Test func acceptedCandidateRemainsProvisionalUntilCloseProbeCommitsIt() { + var arbiter = ConnectionTerminationArbiter() + let candidate = ConnectionTerminationArbiter.Candidate(.closedByCaller) + + #expect(arbiter.claim(candidate) == .accepted(candidate)) + #expect(arbiter.provisionalCandidate == candidate) + #expect(arbiter.winner == nil) + #expect(arbiter.commit(closeObservation: nil) == .closedByCaller) + #expect(arbiter.provisionalCandidate == nil) + #expect(arbiter.winner == .closedByCaller) + } + + @Test func duplicateAndLateClaimsAreClassifiedBeforeAndAfterCommit() { + var arbiter = ConnectionTerminationArbiter() + let caller = ConnectionTerminationArbiter.Candidate(.closedByCaller) + let exit = ConnectionTerminationArbiter.Candidate( + .processExited(status: 9), + observedBeforeTermination: true + ) + + #expect(arbiter.claim(caller) == .accepted(caller)) + #expect(arbiter.claim(caller) == .duplicate(caller)) + #expect(arbiter.claim(exit) == .late(winner: caller, candidate: exit)) + #expect(arbiter.commit(closeObservation: .exited( + status: 9, + observedBeforeTermination: true + )) == .closedByCaller) + #expect(arbiter.claim(caller) == .duplicate(caller)) + #expect(arbiter.claim(exit) == .late(winner: caller, candidate: exit)) + } + + @Test func explicitAndNonEOFFirstCandidatesCannotBeRefinedByCloseProbe() { + let candidates: [ConnectionTerminationArbiter.Candidate] = [ + .init(.closedByCaller), + .init(.transportFailure(.io(errno: 5, message: "read failed"))), + .init(.processExited(status: 1), observedBeforeTermination: true), + ] + let observations: [ConnectionTerminationArbiter.CloseObservation?] = [ + .exited(status: 7, observedBeforeTermination: true), + .failed(.contractViolation(message: "probe failed")), + ] + + for candidate in candidates { + for observation in observations { + var arbiter = ConnectionTerminationArbiter() + #expect(arbiter.claim(candidate) == .accepted(candidate)) + #expect( + arbiter.commit(closeObservation: observation) == candidate.termination + ) + #expect(arbiter.winner == candidate.termination) + } + } + } + + @Test func eofCommitsWhenCloseProbeHasNoMoreSpecificObservation() { + for observation in [ + Optional.none, + .some(.unavailable), + .some(.exited(status: 9, observedBeforeTermination: false)), + .some(.failed(.closed)), + ] { + var arbiter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + #expect(arbiter.claim(eof) == .accepted(eof)) + #expect( + arbiter.commit(closeObservation: observation) == .transportFailure(.closed) + ) + #expect(arbiter.winner == .transportFailure(.closed)) + } + } + + @Test func observedBeforeTerminationProcessExitRefinesProvisionalEOF() { + var arbiter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + let exit = ConnectionTerminationArbiter.Candidate( + .processExited(status: 17), + observedBeforeTermination: true + ) + + #expect(arbiter.claim(eof) == .accepted(eof)) + #expect(arbiter.claim(exit) == .refined(previous: eof, winner: exit)) + #expect(arbiter.provisionalCandidate == exit) + #expect(arbiter.commit(closeObservation: nil) == .processExited(status: 17)) + #expect(arbiter.winner == .processExited(status: 17)) + } + + @Test func closeProbeProcessExitRefinesProvisionalEOFOnlyWhenObservedBeforeTermination() { + var observedBefore = ConnectionTerminationArbiter() + var observedAfter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + #expect(observedBefore.claim(eof) == .accepted(eof)) + #expect(observedAfter.claim(eof) == .accepted(eof)) + + #expect(observedBefore.commit(closeObservation: .exited( + status: 23, + observedBeforeTermination: true + )) == .processExited(status: 23)) + #expect(observedAfter.commit(closeObservation: .exited( + status: 23, + observedBeforeTermination: false + )) == .transportFailure(.closed)) + } + + @Test func beginCloseProbeFailureRefinesProvisionalEOF() { + var arbiter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + let probeFailure = CodexTransportFailure.io(errno: 10, message: "waitid failed") + + #expect(arbiter.claim(eof) == .accepted(eof)) + #expect(arbiter.commit(closeObservation: .failed(probeFailure)) == + .transportFailure(probeFailure)) + #expect(arbiter.winner == .transportFailure(probeFailure)) + } + + @Test func nonEOFSignalsCannotRefineProvisionalEOF() { + var arbiter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + let caller = ConnectionTerminationArbiter.Candidate(.closedByCaller) + let io = ConnectionTerminationArbiter.Candidate( + .transportFailure(.io(errno: 5, message: "read failed")) + ) + let inducedExit = ConnectionTerminationArbiter.Candidate( + .processExited(status: 9), + observedBeforeTermination: false + ) + + #expect(arbiter.claim(eof) == .accepted(eof)) + #expect(arbiter.claim(caller) == .late(winner: eof, candidate: caller)) + #expect(arbiter.claim(io) == .late(winner: eof, candidate: io)) + #expect(arbiter.claim(inducedExit) == .late(winner: eof, candidate: inducedExit)) + #expect(arbiter.commit(closeObservation: nil) == .transportFailure(.closed)) + } + + @Test func eofAndObservedProcessExitConvergeAcrossSignalPermutations() { + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + let exit = ConnectionTerminationArbiter.Candidate( + .processExited(status: 31), + observedBeforeTermination: true + ) + + var eofFirst = ConnectionTerminationArbiter() + #expect(eofFirst.claim(eof) == .accepted(eof)) + #expect(eofFirst.claim(exit) == .refined(previous: eof, winner: exit)) + #expect(eofFirst.commit(closeObservation: nil) == .processExited(status: 31)) + + var exitFirst = ConnectionTerminationArbiter() + #expect(exitFirst.claim(exit) == .accepted(exit)) + #expect(exitFirst.claim(eof) == .late(winner: exit, candidate: eof)) + #expect(exitFirst.commit(closeObservation: nil) == .processExited(status: 31)) + } + + @Test func commitIsIdempotentAndCannotBeReopenedByASecondProbe() { + var arbiter = ConnectionTerminationArbiter() + let eof = ConnectionTerminationArbiter.Candidate(.transportFailure(.closed)) + #expect(arbiter.claim(eof) == .accepted(eof)) + #expect(arbiter.commit(closeObservation: .unavailable) == .transportFailure(.closed)) + #expect(arbiter.commit(closeObservation: .exited( + status: 37, + observedBeforeTermination: true + )) == .transportFailure(.closed)) + #expect(arbiter.winner == .transportFailure(.closed)) + } +} + +@Suite("Connection event hub") +struct ConnectionEventHubTests { + @Test func publicDiagnosticValuesPreserveTheirFields() { + let serverError = CodexServerError(code: -32_001, message: "busy") + let warning = CodexDiagnostic( + message: "configuration warning", + method: "configWarning", + details: "Use the new key." + ) + let retry = CodexRetryDiagnostic( + requestID: 7, + method: "turn/start", + attempt: 1, + delay: .milliseconds(100), + serverError: serverError + ) + let deprecation = CodexDeprecationNotice( + summary: "thread/rollback is deprecated", + details: "Use the replacement when available." + ) + + #expect(warning.message == "configuration warning") + #expect(warning.method == "configWarning") + #expect(warning.details == "Use the new key.") + #expect(retry.requestID == 7) + #expect(retry.method == "turn/start") + #expect(retry.attempt == 1) + #expect(retry.delay == .milliseconds(100)) + #expect(retry.serverError == serverError) + #expect(deprecation.summary == "thread/rollback is deprecated") + #expect(deprecation.details == "Use the replacement when available.") + } + + @Test func liveSubscriberReceivesEveryDiagnosticKindInOrder() async { + let hub = ConnectionEventHub() + let events = hub.events() + let expected: [CodexConnectionEvent] = [ + .warning(.init(message: "warning", method: "warning")), + .retrying(.init( + requestID: 1, + method: "thread/start", + attempt: 1, + delay: .milliseconds(100), + serverError: .init(code: -32_001, message: "busy") + )), + .deprecation(.init(summary: "deprecated", details: "migration guidance")), + .unknown(.init(method: "future/event", params: Data(#"{"value":1}"#.utf8))), + ] + + for event in expected { + hub.yield(event) + } + + var iterator = events.makeAsyncIterator() + for event in expected { + #expect(await iterator.next() == event) + } + await events.cancel() + } + + @Test func slowSubscriberKeepsOnlyNewestThirtyTwoDiagnostics() async { + let hub = ConnectionEventHub() + let events = hub.events() + + for index in 0..<40 { + hub.yield(.warning(.init(message: "warning-\(index)"))) + } + + var iterator = events.makeAsyncIterator() + var messages: [String] = [] + for _ in 0..<32 { + guard case .warning(let diagnostic) = await iterator.next() else { + Issue.record("Expected a warning diagnostic.") + return + } + messages.append(diagnostic.message) + } + #expect(messages == (8..<40).map { "warning-\($0)" }) + await events.cancel() + } + + @Test func nextCallOwnershipRejectsOverlapEvenWhenDiagnosticsAreBuffered() async { + let hub = ConnectionEventHub() + let events = hub.events() + hub.yield(.warning(.init(message: "first"))) + hub.yield(.warning(.init(message: "second"))) + + #expect(events.claimNextCallForTesting()) + #expect(events.claimNextCallForTesting() == false) + events.endNextCallForTesting() + + var iterator = events.makeAsyncIterator() + #expect(await iterator.next() == .warning(.init(message: "first"))) + #expect(await iterator.next() == .warning(.init(message: "second"))) + await events.cancel() + } + + @Test func deliveredDiagnosticPrecedesLaterSynchronousTerminal() async { + let hub = ConnectionEventHub() + let events = hub.events() + let first = Task { + var iterator = events.makeAsyncIterator() + return await iterator.next() + } + await events.waitUntilNextSuspendsForTesting() + + hub.yield(.warning(.init(message: "before terminal"))) + hub.finish(with: .closedByCaller) + + #expect(await first.value == .warning(.init(message: "before terminal"))) + var iterator = events.makeAsyncIterator() + #expect(await iterator.next() == .terminated(.closedByCaller)) + #expect(await iterator.next() == nil) + } + + @Test func terminalSupersedesAFullDiagnosticBufferAndIsNeverDropped() async { + let hub = ConnectionEventHub() + let events = hub.events() + for index in 0..<32 { + hub.yield(.warning(.init(message: "warning-\(index)"))) + } + + hub.finish(with: .processExited(status: 17)) + + var iterator = events.makeAsyncIterator() + #expect(await iterator.next() == .terminated(.processExited(status: 17))) + #expect(await iterator.next() == nil) + #expect(await iterator.next() == nil) + } + + @Test func terminalImmediatelyResumesAWaitingSubscriber() async { + let hub = ConnectionEventHub() + let events = hub.events() + let waiter = Task { + var iterator = events.makeAsyncIterator() + return await iterator.next() + } + await events.waitUntilNextSuspendsForTesting() + + hub.finish(with: .transportFailure(.closed)) + + #expect(await waiter.value == .terminated(.transportFailure(.closed))) + } + + @Test func lateSubscriberReceivesOnlyTheCompactTerminal() async { + let hub = ConnectionEventHub() + let early = hub.events() + hub.yield(.warning(.init(message: "old warning"))) + hub.finish(with: .closedByCaller) + hub.finish(with: .closedByCaller) + + let late = hub.events() + var earlyIterator = early.makeAsyncIterator() + var lateIterator = late.makeAsyncIterator() + #expect(await earlyIterator.next() == .terminated(.closedByCaller)) + #expect(await earlyIterator.next() == nil) + #expect(await lateIterator.next() == .terminated(.closedByCaller)) + #expect(await lateIterator.next() == nil) + #expect(hub.snapshotForTesting().subscriberCount == 0) + #expect(hub.snapshotForTesting().terminal == .closedByCaller) + } + + @Test func diagnosticsAfterTerminalCannotReplaceReplay() async { + let hub = ConnectionEventHub() + hub.finish(with: .closedByCaller) + hub.yield(.warning(.init(message: "too late"))) + + let events = hub.events() + var iterator = events.makeAsyncIterator() + #expect(await iterator.next() == .terminated(.closedByCaller)) + #expect(await iterator.next() == nil) + } + + @Test func explicitCancellationIsSynchronousIdempotentAndSubscriberLocal() async { + let hub = ConnectionEventHub() + let cancelled = hub.events() + let remaining = hub.events() + #expect(hub.snapshotForTesting().subscriberCount == 2) + + await cancelled.cancel() + #expect(hub.snapshotForTesting().subscriberCount == 1) + await cancelled.cancel() + #expect(hub.snapshotForTesting().subscriberCount == 1) + + hub.yield(.warning(.init(message: "remaining"))) + var cancelledIterator = cancelled.makeAsyncIterator() + var remainingIterator = remaining.makeAsyncIterator() + #expect(await cancelledIterator.next() == nil) + #expect(await remainingIterator.next() == .warning(.init(message: "remaining"))) + await remaining.cancel() + #expect(hub.snapshotForTesting().subscriberCount == 0) + } + + @Test func taskCancellationSynchronouslyReleasesOnlyItsSubscription() async { + let hub = ConnectionEventHub() + let cancelled = hub.events() + let remaining = hub.events() + let waiter = Task { + var iterator = cancelled.makeAsyncIterator() + return await iterator.next() + } + await cancelled.waitUntilNextSuspendsForTesting() + + waiter.cancel() + + #expect(await waiter.value == nil) + #expect(hub.snapshotForTesting().subscriberCount == 1) + hub.yield(.warning(.init(message: "still active"))) + var iterator = remaining.makeAsyncIterator() + #expect(await iterator.next() == .warning(.init(message: "still active"))) + await remaining.cancel() + } + + @Test func lastSequenceOrIteratorCopyReleaseUnsubscribesSynchronously() async { + let hub = ConnectionEventHub() + var events: CodexConnectionEvents? = hub.events() + var copy = events + var iterator: CodexConnectionEvents.Iterator? = copy?.makeAsyncIterator() + #expect(hub.snapshotForTesting().subscriberCount == 1) + + events = nil + copy = nil + #expect(hub.snapshotForTesting().subscriberCount == 1) + withExtendedLifetime(iterator) {} + iterator = nil + + #expect(hub.snapshotForTesting().subscriberCount == 0) + } + + @Test func subscriptionDoesNotRetainItsHubOrAConnectionOwner() async { + let detached = makeEventsFromEphemeralHub() + + #expect(detached.hub.value == nil) + var iterator = detached.events.makeAsyncIterator() + #expect(await iterator.next() == nil) + } + + @Test func fastSubscriberIsUnaffectedBySlowSubscriberOverflow() async { + let hub = ConnectionEventHub() + let fast = hub.events() + let slow = hub.events() + var fastIterator = fast.makeAsyncIterator() + + for index in 0..<40 { + let event = CodexConnectionEvent.warning(.init(message: "warning-\(index)")) + hub.yield(event) + #expect(await fastIterator.next() == event) + } + + var slowIterator = slow.makeAsyncIterator() + var slowMessages: [String] = [] + for _ in 0..<32 { + guard case .warning(let diagnostic) = await slowIterator.next() else { + Issue.record("Expected a warning diagnostic.") + return + } + slowMessages.append(diagnostic.message) + } + #expect(slowMessages == (8..<40).map { "warning-\($0)" }) + await fast.cancel() + await slow.cancel() + } +} + +@Suite("Connection event integration") +struct ConnectionEventIntegrationTests { + @Test func stderrDiagnosticsPreserveFilterSeverityAndIOStage() { + #expect(ConnectionDiagnosticFactory.processStderr(.init( + level: .error, + message: "plain stderr" + )) == .init( + message: "plain stderr", + method: "process/stderr", + details: "severity: error" + )) + #expect(ConnectionDiagnosticFactory.processStderr(.init( + level: .warning, + message: "command output omitted" + )) == .init( + message: "command output omitted", + method: "process/stderr", + details: "severity: warning" + )) + #expect(ConnectionDiagnosticFactory.processStderrFailure( + .setup, + details: "Bad file descriptor" + ) == .init( + message: "App-server stderr setup failed.", + method: "process/stderr", + details: "Bad file descriptor" + )) + #expect(ConnectionDiagnosticFactory.processStderrFailure( + .read, + details: "Input/output error" + ) == .init( + message: "App-server stderr read failed.", + method: "process/stderr", + details: "Input/output error" + )) + } + + @Test func publicStreamReceivesDecodedDiagnosticsAndTheCommittedTerminal() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let events = await runtime.server.connectionEvents() + var iterator = events.makeAsyncIterator() + + try await runtime.transport.emitServerNotificationJSON( + method: "warning", + json: #"{"message":"wire warning"}"# + ) + try await runtime.transport.emitServerNotificationJSON( + method: "deprecationNotice", + json: #"{"summary":"old API","details":"use new API"}"# + ) + try await runtime.transport.emitServerNotificationJSON( + method: "configWarning", + json: #"{"summary":"bad key","details":"remove it","path":"config.toml","range":{"start":{"line":1,"column":2},"end":{"line":1,"column":5}}}"# + ) + try await runtime.transport.emitServerNotificationJSON( + method: "future/notification", + json: #"{"threadId":"thread-1","turnId":"turn-1","value":1}"# + ) + + #expect(await iterator.next() == .warning(.init( + message: "wire warning", + method: "warning" + ))) + #expect(await iterator.next() == .deprecation(.init( + summary: "old API", + details: "use new API" + ))) + #expect(await iterator.next() == .warning(.init( + message: "bad key", + method: "configWarning", + details: "remove it" + ))) + guard case .unknown(let raw) = await iterator.next() else { + Issue.record("Expected an unknown connection notification.") + return + } + #expect(raw.method == "future/notification") + #expect(raw.threadID == "thread-1") + #expect(raw.turnID == "turn-1") + + await runtime.server.close() + #expect(await iterator.next() == .terminated(.closedByCaller)) + #expect(await iterator.next() == nil) + } + + @Test func latePublicSubscriberReplaysOnlyTheCommittedTerminal() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + await runtime.server.close() + + var iterator = await runtime.server.connectionEvents().makeAsyncIterator() + #expect(await iterator.next() == .terminated(.closedByCaller)) + #expect(await iterator.next() == nil) + } +} + +private final class WeakReference: @unchecked Sendable { + weak var value: Value? + + init(_ value: Value) { + self.value = value + } +} + +private func makeEventsFromEphemeralHub() -> ( + events: CodexConnectionEvents, + hub: WeakReference +) { + let hub = ConnectionEventHub() + return (hub.events(), WeakReference(hub)) +} diff --git a/Tests/CodexAppServerKitTests/ConnectionLifecycleTests.swift b/Tests/CodexAppServerKitTests/ConnectionLifecycleTests.swift new file mode 100644 index 00000000..e71a516f --- /dev/null +++ b/Tests/CodexAppServerKitTests/ConnectionLifecycleTests.swift @@ -0,0 +1,715 @@ +import Darwin +import Foundation +import Testing + +import CodexAppServerKitTesting +@testable import CodexAppServerKit + +@Suite("Connection lifecycle") +struct ConnectionLifecycleTests { + @Test func framerUsesSixteenMiBWireLimit() throws { + #expect(JSONRPC.Framer.maximumFrameByteCount == 16 * 1_024 * 1_024) + + var framer = JSONRPC.Framer(maximumFrameByteCount: 3) + #expect(try framer.append(0x61) == nil) + #expect(try framer.append(0x62) == nil) + #expect(try framer.append(0x63) == nil) + #expect(try framer.append(0x0A) == Data("abc".utf8)) + + _ = try framer.append(0x61) + _ = try framer.append(0x62) + _ = try framer.append(0x63) + #expect(throws: CodexTransportFailure.self) { + _ = try framer.append(0x64) + } + } + + @Test func mailboxOwnsReadySixteenPlusOneOverflowAndDrainsOnClose() async throws { + let mailbox = JSONRPCInboundFrameMailbox() + let accepted = (0..<17).map { Data([UInt8($0)]) } + for frame in accepted { + try await mailbox.send(frame) + } + + #expect(await mailbox.snapshot() == .init( + readyFrameCount: 16, + hasOverflowFrame: true, + admissionWaiterCount: 0, + isTerminal: false + )) + + let unaccepted = Task { + try await mailbox.send(Data([17])) + } + await mailbox.waitForAdmissionWaiterCount(atLeast: 1) + #expect(await mailbox.snapshot().acceptedFrameCount == 17) + + await mailbox.finish() + await #expect(throws: CodexTransportFailure.self) { + try await unaccepted.value + } + await #expect(throws: CodexTransportFailure.self) { + try await mailbox.send(Data([18])) + } + + var drained: [Data] = [] + while let frame = try await mailbox.next() { + drained.append(frame) + } + #expect(drained == accepted) + } + + @Test func cancellingAdmissionWaiterDoesNotTransferItsFrame() async throws { + let mailbox = JSONRPCInboundFrameMailbox() + for value in 0..<17 { + try await mailbox.send(Data([UInt8(value)])) + } + + let blocked = Task { + try await mailbox.send(Data([17])) + } + await mailbox.waitForAdmissionWaiterCount(atLeast: 1) + blocked.cancel() + await #expect(throws: CancellationError.self) { + try await blocked.value + } + + let snapshot = await mailbox.snapshot() + #expect(snapshot.acceptedFrameCount == 17) + #expect(snapshot.admissionWaiterCount == 0) + } + + @Test func cancelledReceiverDoesNotConsumeFrameOrTerminalFailure() async throws { + let mailbox = JSONRPCInboundFrameMailbox() + let receiver = Task { + try await mailbox.next() + } + await mailbox.waitUntilReceiverIsRegistered() + receiver.cancel() + await #expect(throws: CancellationError.self) { + try await receiver.value + } + + let frame = Data("frame".utf8) + let failure = CodexTransportFailure.protocolViolation( + message: "terminal", + rawData: nil + ) + try await mailbox.send(frame) + await mailbox.finish(throwing: failure) + + #expect(try await mailbox.next() == frame) + do { + _ = try await mailbox.next() + Issue.record("Expected the terminal failure after the accepted frame.") + } catch let observed as CodexTransportFailure { + #expect(observed == failure) + } + } + + @Test func concurrentProducerAdmissionIsFIFO() async throws { + let mailbox = JSONRPCInboundFrameMailbox(readyCapacity: 1) + try await mailbox.send(Data([UInt8(0)])) + try await mailbox.send(Data([UInt8(1)])) + + var producers: [Task] = [] + for value in 2...4 { + producers.append(Task { + try await mailbox.send(Data([UInt8(value)])) + }) + await mailbox.waitForAdmissionWaiterCount(atLeast: value - 1) + } + + var drained: [Data] = [] + for (index, producer) in producers.enumerated() { + drained.append(try #require(try await mailbox.next())) + try await producer.value + #expect(await mailbox.snapshot().admissionWaiterCount == producers.count - index - 1) + } + drained.append(try #require(try await mailbox.next())) + drained.append(try #require(try await mailbox.next())) + + #expect(drained == (0...4).map { Data([UInt8($0)]) }) + } + + @Test func liveReaderBoundsChunkAndDropsOnlyUnacceptedRemainderOnClose() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + let payload = (0..<40) + .map { "{\"method\":\"future/event\",\"params\":{\"index\":\($0)}}" } + .joined(separator: "\n") + "\n" + try Data(payload.utf8).write(to: fixture.payloadURL) + try fixture.installExecutable( + """ + #!/bin/sh + cat "$PAYLOAD_PATH" + while :; do sleep 1; done + """ + ) + + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(environment: [ + "PAYLOAD_PATH": fixture.payloadURL.path, + ]), + connectionEventHub: ConnectionEventHub() + ) + await transport.waitForInboundAdmissionWaiterCountForTesting(atLeast: 1) + + let mailboxBeforeClose = await transport.inboundMailboxSnapshotForTesting() + let stdoutBeforeClose = await transport.stdoutReadSnapshotForTesting() + #expect(mailboxBeforeClose.acceptedFrameCount == 17) + #expect(mailboxBeforeClose.admissionWaiterCount == 1) + #expect(stdoutBeforeClose.successfulReadCount > 0) + #expect(stdoutBeforeClose.maximumChunkByteCount <= AppServerProcessTransport.stdoutReadChunkByteCount) + + _ = await transport.beginClose() + var receivedIndexes: [Int] = [] + for _ in 0..<17 { + guard case .notification(let notification) = try await transport.nextInboundEvent() + else { + Issue.record("Expected an accepted notification.") + return + } + let object = try #require( + JSONSerialization.jsonObject(with: notification.params) as? [String: Int] + ) + receivedIndexes.append(try #require(object["index"])) + } + #expect(try await transport.nextInboundEvent() == nil) + await transport.finishPendingResponsesAfterInboundDrain(.closed) + + let observation = await transport.waitForProcessExit() + guard case .exited = observation else { + Issue.record("Expected the terminated child process to exit, got \(observation).") + return + } + await transport.waitUntilClosed() + let stdoutAfterClose = await transport.stdoutReadSnapshotForTesting() + #expect(receivedIndexes == Array(0..<17)) + #expect(stdoutAfterClose.successfulReadCount == stdoutBeforeClose.successfulReadCount) + #expect(stdoutAfterClose.currentChunkRemainderByteCount == 0) + #expect(stdoutAfterClose.sourceCancellationCompleted) + + let beforeReap = await transport.processLifecycleSnapshotForTesting() + #expect(beforeReap.didObserveExit) + #expect(beforeReap.didReap == false) + await transport.reapProcess() + await transport.reapProcess() + let afterReap = await transport.processLifecycleSnapshotForTesting() + #expect(afterReap.didReap) + #expect(afterReap.reapSystemCallCount == 1) + } + + @Test func processIgnoringTermIsKilledBeforeExitObservationAndReapedOnce() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + trap '' TERM + printf '%s\n' '{"method":"ready","params":{}}' + while :; do sleep 1; done + """ + ) + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: ConnectionEventHub() + ) + guard case .notification = try await transport.nextInboundEvent() else { + Issue.record("Expected the child readiness notification.") + return + } + + _ = await transport.beginClose() + let observation = await transport.waitForProcessExit() + #expect(observation == .exited(status: -SIGKILL, observedBeforeTermination: false)) + await transport.waitUntilClosed() + await transport.reapProcess() + await transport.reapProcess() + #expect(await transport.processLifecycleSnapshotForTesting().reapSystemCallCount == 1) + } + + @Test func exitReadinessWaitsForWaitIDStatusPublication() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + sleep 0.01 + exit 0 + """ + ) + + for _ in 0..<20 { + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: ConnectionEventHub() + ) + let observation = await transport.waitForProcessExit() + #expect(observation == .exited(status: 0, observedBeforeTermination: true)) + + _ = await transport.beginClose() + #expect(try await transport.nextInboundEvent() == nil) + await transport.finishPendingResponsesAfterInboundDrain(.closed) + await transport.waitUntilClosed() + await transport.reapProcess() + #expect(await transport.processLifecycleSnapshotForTesting().reapSystemCallCount == 1) + } + } + + @Test func explicitCloseWhileInboundIsIdleCompletesRouterAndReapsOnce() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + while :; do sleep 1; done + """ + ) + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: ConnectionEventHub() + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + processTerminationToken: transport.processTerminationToken + ) + await transport.waitUntilInboundReceiverIsRegisteredForTesting() + + await harness.close() + await harness.supervisor.waitUntilClosed() + + #expect(await harness.supervisor.terminationForTesting() == .closedByCaller) + let lifecycle = await transport.processLifecycleSnapshotForTesting() + #expect(lifecycle.didObserveExit) + #expect(lifecycle.didReap) + #expect(lifecycle.reapSystemCallCount == 1) + } + + @Test func unknownResponseWhileOpenTerminatesTheConnection() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let connectionEvents = transport.connectionEventHub.events() + let warningTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.waitForNotificationStreamCount(1) + let frame = Data(#"{"id":999,"result":{}}"#.utf8) + + try await transport.emitRawInboundFrame(frame) + let termination = await harness.supervisor.waitForTerminationForTesting() + guard case .transportFailure(.protocolViolation(_, let rawData)) = termination else { + Issue.record("Expected an unknown response protocol violation, got \(termination).") + return + } + #expect(rawData == frame) + await harness.supervisor.waitUntilClosed() + guard case .warning(let warning) = await warningTask.value else { + Issue.record("Expected the open-state protocol violation diagnostic.") + return + } + #expect(warning.message.contains("unknown request id 999")) + var terminalIterator = connectionEvents.makeAsyncIterator() + #expect(await terminalIterator.next() == .terminated(termination)) + } + + @Test func unknownResponseAcceptedBeforeCloseDoesNotReplaceCloseWinner() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let connectionEvents = transport.connectionEventHub.events() + let warningTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.waitForNotificationStreamCount(1) + let gate = CodexAppServerTestGate() + await transport.holdNextInboundEventDelivery(at: gate) + + try await transport.emitRawInboundFrame(Data(#"{"id":999,"result":{}}"#.utf8)) + await transport.waitUntilInboundEventDeliveryIsHeld() + await harness.close() + + #expect(await harness.supervisor.terminationForTesting() == .closedByCaller) + #expect(await warningTask.value == .warning(.init( + message: "Ignored late JSON-RPC response after outbound close.", + details: "requestId: 999" + ))) + var terminalIterator = connectionEvents.makeAsyncIterator() + #expect(await terminalIterator.next() == .terminated(.closedByCaller)) + } + + @Test func responsesOnlyDrainReportsDroppedNotificationsAndServerRequests() async throws { + let transport = ResponsesOnlyDiagnosticTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let connectionEvents = transport.connectionEventHub.events() + + let routingFailureTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.releaseMalformedNotification() + guard case .warning(let routingFailure) = await routingFailureTask.value else { + Issue.record("Expected the routing-failure diagnostic.") + return + } + #expect(routingFailure.method == "turn/completed") + + let droppedNotificationTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.releaseDroppedNotification() + #expect(await droppedNotificationTask.value == .warning(.init( + message: "Dropped notification while draining responses after routing failure.", + method: "future/notification" + ))) + + let droppedRequestTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.releaseDroppedServerRequest() + #expect(await droppedRequestTask.value == .warning(.init( + message: "Dropped server request while draining responses after routing failure.", + method: "item/commandExecution/requestApproval", + details: "requestId: request-after-failure" + ))) + + let terminalTask = Task { + var iterator = connectionEvents.makeAsyncIterator() + return await iterator.next() + } + await connectionEvents.waitUntilNextSuspendsForTesting() + await transport.releaseInboundTerminal() + await harness.supervisor.waitUntilClosed() + guard case .terminated(.transportFailure(.protocolViolation(_, _))) = await terminalTask.value + else { + Issue.record("Expected the malformed notification terminal.") + return + } + } + + @Test func liveTransportReportsAcceptedResponseThatBecomesLateDuringClose() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + printf '%s\n' '{"method":"ready","params":{}}' + printf '%s\n' '{"id":999,"result":{}}' + while :; do sleep 1; done + """ + ) + let connectionEventHub = ConnectionEventHub() + var connectionEvents = connectionEventHub.events().makeAsyncIterator() + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: connectionEventHub + ) + guard case .notification = try await transport.nextInboundEvent() else { + Issue.record("Expected the readiness notification.") + return + } + for _ in 0..<1_000 { + if await transport.inboundMailboxSnapshotForTesting().acceptedFrameCount > 0 { + break + } + try await Task.sleep(for: .milliseconds(1)) + } + #expect(await transport.inboundMailboxSnapshotForTesting().acceptedFrameCount == 1) + + _ = await transport.beginClose() + #expect(try await transport.nextInboundEvent() == nil) + #expect(await connectionEvents.next() == .warning(.init( + message: "Ignored late JSON-RPC response after outbound close.", + details: "requestId: 999" + ))) + await transport.finishPendingResponsesAfterInboundDrain(.closed) + _ = await transport.waitForProcessExit() + await transport.waitUntilClosed() + await transport.reapProcess() + } + + @Test func liveTransportProjectsStderrErrorAndWarningLevels() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + printf '%s\n' 'plain stderr' 'codex_core::tools::router: error=failed' 'Output:' >&2 + while :; do sleep 1; done + """ + ) + let connectionEventHub = ConnectionEventHub() + var connectionEvents = connectionEventHub.events().makeAsyncIterator() + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: connectionEventHub + ) + + #expect(await connectionEvents.next() == .warning(.init( + message: "plain stderr", + method: "process/stderr", + details: "severity: error" + ))) + #expect(await connectionEvents.next() == .warning(.init( + message: "codex_core::tools::router: error=failed", + method: "process/stderr", + details: "severity: error" + ))) + #expect(await connectionEvents.next() == .warning(.init( + message: "command output omitted after tool error", + method: "process/stderr", + details: "severity: warning" + ))) + + _ = await transport.beginClose() + #expect(try await transport.nextInboundEvent() == nil) + await transport.finishPendingResponsesAfterInboundDrain(.closed) + _ = await transport.waitForProcessExit() + await transport.waitUntilClosed() + await transport.reapProcess() + } + + @Test func lateTerminationIsDiagnosedBeforeTheWinningTerminalFinishesTheSameHub() async { + let transport = LateTerminationTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let events = await harness.server.connectionEvents() + var iterator = events.makeAsyncIterator() + #expect(transport.connectionEventHub === harness.connection.connectionEventHub) + + let closeTask = Task { + await harness.close() + } + guard case .warning(let warning) = await iterator.next() else { + Issue.record("Expected a late-termination diagnostic before terminal finish.") + return + } + #expect(warning.message == "Ignored late connection termination.") + #expect(warning.details?.contains("closedByCaller") == true) + #expect(warning.details?.contains("late process observation") == true) + #expect(transport.connectionEventHub.snapshotForTesting().terminal == nil) + + await transport.releaseInbound() + await closeTask.value + #expect(await iterator.next() == .terminated(.closedByCaller)) + #expect(await iterator.next() == nil) + } + + @Test func writerFailureClaimsTypedTerminalAndRunsFullClose() async throws { + let fixture = try ProcessFixture() + defer { fixture.remove() } + try fixture.installExecutable( + """ + #!/bin/sh + while :; do sleep 1; done + """ + ) + let transport = try AppServerProcessTransport( + configuration: fixture.configuration(), + connectionEventHub: ConnectionEventHub(), + writerFactory: { fileHandle in + AppServerJSONRPCWriter(fileHandle: fileHandle) { _ in + throw POSIXError(.EPIPE) + } + } + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + processTerminationToken: transport.processTerminationToken + ) + + await #expect(throws: CodexTransportFailure.self) { + try await transport.notify(.init(method: "test", params: Data("{}".utf8))) + } + let termination = await harness.supervisor.waitForTerminationForTesting() + guard case .transportFailure(.io(let errorNumber, _)) = termination else { + Issue.record("Expected a typed writer I/O failure, got \(termination).") + return + } + #expect(errorNumber == EPIPE) + await #expect(throws: JSONRPC.Error.closed) { + try await transport.notify(.init(method: "after-close", params: Data("{}".utf8))) + } + await harness.supervisor.waitUntilClosed() + } +} + +private actor LateTerminationTestTransport: JSONRPC.Transport { + nonisolated let connectionEventHub = ConnectionEventHub() + private let inboundGate = CodexAppServerTestGate() + private let processExitGate = CodexAppServerTestGate() + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + throw JSONRPC.Error.closed + } + + func notify(_ notification: JSONRPC.Notification) async throws { + throw JSONRPC.Error.closed + } + + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + try await inboundGate.wait() + return nil + } + + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws { + throw JSONRPC.Error.closed + } + + func beginClose() async -> JSONRPC.ProcessExitObservation? { + await processExitGate.open() + return nil + } + + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async {} + + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { + await processExitGate.waitIgnoringCancellation() + return .failed(.io(errno: EIO, message: "late process observation")) + } + + func waitUntilClosed() async {} + + func reapProcess() async {} + + func releaseInbound() async { + await inboundGate.open() + } +} + +private actor ResponsesOnlyDiagnosticTestTransport: JSONRPC.Transport { + nonisolated let connectionEventHub = ConnectionEventHub() + private let malformedNotificationGate = CodexAppServerTestGate() + private let droppedNotificationGate = CodexAppServerTestGate() + private let droppedServerRequestGate = CodexAppServerTestGate() + private let inboundTerminalGate = CodexAppServerTestGate() + private var inboundIndex = 0 + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + throw JSONRPC.Error.closed + } + + func notify(_ notification: JSONRPC.Notification) async throws { + throw JSONRPC.Error.closed + } + + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + defer { inboundIndex += 1 } + switch inboundIndex { + case 0: + try await malformedNotificationGate.wait() + return .notification(.init( + method: "turn/completed", + params: Data(#"{}"#.utf8) + )) + case 1: + try await droppedNotificationGate.wait() + return .notification(.init( + method: "future/notification", + params: Data(#"{}"#.utf8) + )) + case 2: + try await droppedServerRequestGate.wait() + return .serverRequest( + id: .string("request-after-failure"), + method: "item/commandExecution/requestApproval", + params: Data(#"{}"#.utf8) + ) + case 3: + try await inboundTerminalGate.wait() + return nil + default: + preconditionFailure("The responses-only test transport has a fixed inbound script.") + } + } + + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws { + throw JSONRPC.Error.closed + } + + func beginClose() async -> JSONRPC.ProcessExitObservation? { nil } + + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async {} + + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { .unavailable } + + func waitUntilClosed() async {} + + func reapProcess() async {} + + func releaseMalformedNotification() async { + await malformedNotificationGate.open() + } + + func releaseDroppedNotification() async { + await droppedNotificationGate.open() + } + + func releaseDroppedServerRequest() async { + await droppedServerRequestGate.open() + } + + func releaseInboundTerminal() async { + await inboundTerminalGate.open() + } +} + +private struct ProcessFixture { + let rootURL: URL + let executableURL: URL + let payloadURL: URL + let codexHomeURL: URL + + init() throws { + rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + executableURL = rootURL.appendingPathComponent("fake-app-server") + payloadURL = rootURL.appendingPathComponent("payload.jsonl") + codexHomeURL = rootURL.appendingPathComponent("codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + } + + func installExecutable(_ contents: String) throws { + try contents.write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + } + + func configuration( + environment: [String: String] = [:] + ) -> AppServerProcessTransport.Configuration { + var environment = environment + environment["PATH"] = environment["PATH"] ?? "/usr/bin:/bin" + return .init( + executable: executableURL.path, + arguments: [], + environment: environment, + codexHomeURL: codexHomeURL + ) + } + + func remove() { + try? FileManager.default.removeItem(at: rootURL) + } +} diff --git a/Tests/CodexAppServerKitTests/InterruptRaceResolverTests.swift b/Tests/CodexAppServerKitTests/InterruptRaceResolverTests.swift new file mode 100644 index 00000000..455783af --- /dev/null +++ b/Tests/CodexAppServerKitTests/InterruptRaceResolverTests.swift @@ -0,0 +1,133 @@ +import Testing +@testable import CodexAppServerKit +@testable import CodexAppServerKitTesting + +@Suite("Interrupt race resolver") +struct InterruptRaceResolverTests { + @Test func retriesOnlyThePinnedNoActiveMessageWithFixedBudget() { + var resolver = InterruptRaceResolver(expectedTurnID: "turn-1") + let failure = serverFailure("no active turn to interrupt") + + for _ in 0..<5 { + guard case .retry(let delay) = resolver.decision(for: failure) else { + Issue.record("Expected the pinned activation race to retry.") + return + } + #expect(delay == .milliseconds(50)) + } + guard case .fail = resolver.decision(for: failure) else { + Issue.record("Expected the fixed retry budget to be exhausted.") + return + } + } + + @Test func similarNoActiveMessagesAreNotClassifiedAsThePinnedRace() { + for message in [ + "No active turn to interrupt", + "no active turn available to interrupt", + "no active turn to interrupt ", + ] { + var resolver = InterruptRaceResolver(expectedTurnID: "turn-1") + guard case .fail = resolver.decision(for: serverFailure(message)) else { + Issue.record("Unexpected retry for \(message.debugDescription).") + continue + } + } + } + + @Test func redirectsOnlyOneExactExpectedTurnMismatch() { + var resolver = InterruptRaceResolver(expectedTurnID: "turn-old") + let mismatch = serverFailure( + "expected active turn id turn-old but found turn-new" + ) + guard case .redirect(let turnID) = resolver.decision(for: mismatch) else { + Issue.record("Expected an exact active-turn redirect.") + return + } + #expect(turnID == "turn-new") + + guard case .fail = resolver.decision(for: mismatch) else { + Issue.record("A second mismatch must not redirect again.") + return + } + + var wrongExpected = InterruptRaceResolver(expectedTurnID: "turn-other") + guard case .fail = wrongExpected.decision(for: mismatch) else { + Issue.record("A mismatch for another expected turn must remain an error.") + return + } + + var malformedActual = InterruptRaceResolver(expectedTurnID: "turn-old") + guard case .fail = malformedActual.decision(for: serverFailure( + "expected active turn id turn-old but found turn-new trailing" + )) else { + Issue.record("A non-exact active turn ID must remain an error.") + return + } + } + + @Test func startupInterruptNeverParsesAnExpectedTurnMismatch() { + var resolver = InterruptRaceResolver(expectedTurnID: nil) + let impossibleForPinnedAppServer = serverFailure( + "expected active turn id but found turn-new" + ) + + guard case .fail = resolver.decision(for: impossibleForPinnedAppServer) else { + Issue.record("An unguarded startup interrupt must not infer a turn identity.") + return + } + } + + @Test func interruptRetryUsesTheInjectedMonotonicClock() async throws { + let recorder = InterruptSleepRecorder() + let transport = CodexAppServerTestTransport() + try await transport.enqueue( + AppServerAPI.Turn.Start.Response(turn: .init(id: "turn-1", status: "running")), + for: "turn/start" + ) + await transport.enqueueFailure( + code: -32602, + message: "no active turn to interrupt", + for: "turn/interrupt" + ) + try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + deadlineClock: .init { duration in + await recorder.record(duration) + } + ) + let thread = CodexThread( + id: "thread-1", + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + + let stream = try await thread.streamResponse(to: "Run checks.") + _ = try await stream.cancel() + + #expect(await recorder.values() == [.milliseconds(50)]) + } +} + +private actor InterruptSleepRecorder { + private var recorded: [Duration] = [] + + func record(_ duration: Duration) { + recorded.append(duration) + } + + func values() -> [Duration] { + recorded + } +} + +private func serverFailure(_ message: String) -> CodexAppServerError { + .request(.init( + requestID: 1, + method: "turn/interrupt", + purpose: .operation("turn/interrupt"), + kind: .server(.init(code: -32602, message: message)) + )) +} diff --git a/Tests/CodexAppServerKitTests/JSONRPCEnvelopeCodecTests.swift b/Tests/CodexAppServerKitTests/JSONRPCEnvelopeCodecTests.swift new file mode 100644 index 00000000..c18c964a --- /dev/null +++ b/Tests/CodexAppServerKitTests/JSONRPCEnvelopeCodecTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit + +@Suite("JSON-RPC envelope codec") +struct JSONRPCEnvelopeCodecTests { + @Test func responseRequiresExactlyOneResultOrError() throws { + for json in [ + #"{"id":1}"#, + #"{"id":1,"result":{},"error":{"code":-1,"message":"failure"}}"#, + ] { + let data = Data(json.utf8) + do { + _ = try JSONRPC.decodeInboundEnvelope(data) + Issue.record("Expected a protocol violation for \(json).") + } catch let failure as CodexTransportFailure { + guard case .protocolViolation(_, let rawData) = failure else { + Issue.record("Expected a protocol violation, got \(failure).") + continue + } + #expect(rawData == data) + } + } + } + + @Test func responseRequiresStrictErrorShape() throws { + for json in [ + #"{"id":1,"error":null}"#, + #"{"id":1,"error":[]}"#, + #"{"id":1,"error":{"message":"failure"}}"#, + #"{"id":1,"error":{"code":"-1","message":"failure"}}"#, + #"{"id":1,"error":{"code":true,"message":"failure"}}"#, + #"{"id":1,"error":{"code":1.5,"message":"failure"}}"#, + #"{"id":1,"error":{"code":1e100,"message":"failure"}}"#, + #"{"id":1,"error":{"code":-1}}"#, + #"{"id":1,"error":{"code":-1,"message":false}}"#, + ] { + let data = Data(json.utf8) + do { + _ = try JSONRPC.decodeInboundEnvelope(data) + Issue.record("Expected a protocol violation for \(json).") + } catch let failure as CodexTransportFailure { + guard case .protocolViolation(_, let rawData) = failure else { + Issue.record("Expected a protocol violation, got \(failure).") + continue + } + #expect(rawData == data) + } + } + } + + @Test func responseRequiresExactIntegerID() throws { + for json in [ + #"{"id":true,"result":{}}"#, + #"{"id":1.5,"result":{}}"#, + #"{"id":1e100,"result":{}}"#, + ] { + let data = Data(json.utf8) + #expect(throws: CodexTransportFailure.self) { + _ = try JSONRPC.decodeInboundEnvelope(data) + } + } + } + + @Test func responsePreservesJSONNullPayloads() throws { + let resultFrame = Data(#"{"id":1,"result":null}"#.utf8) + guard case .response(1, .success(let result)) = try JSONRPC.decodeInboundEnvelope( + resultFrame + ) else { + Issue.record("Expected a successful response.") + return + } + #expect(result == Data("null".utf8)) + + let errorFrame = Data( + #"{"id":2,"error":{"code":-32000,"message":"failure","data":null}}"#.utf8 + ) + guard case .response(2, .failure(.responseError(let error))) = + try JSONRPC.decodeInboundEnvelope(errorFrame) else { + Issue.record("Expected an error response.") + return + } + #expect(error.data == Data("null".utf8)) + #expect(try AppServerProcessTransport.responsePayloadData(from: NSNull()) == Data("null".utf8)) + } +} diff --git a/Tests/CodexAppServerKitTests/ServerRequestRegistryTests.swift b/Tests/CodexAppServerKitTests/ServerRequestRegistryTests.swift new file mode 100644 index 00000000..5ae3d503 --- /dev/null +++ b/Tests/CodexAppServerKitTests/ServerRequestRegistryTests.swift @@ -0,0 +1,689 @@ +import Foundation +import Synchronization +import Testing + +import CodexAppServerKitTesting +@testable import CodexAppServerKit + +@Suite("Server request registry") +struct ServerRequestRegistryTests { + @Test func testingInjectorRoutesIntegerAndStringIDsThroughCodecAndRegistry() async throws { + let injector = await CodexAppServerTestServerRequestInjector() + let ids: [CodexServerRequestID] = [.integer(42), .string("approval-42")] + + for id in ids { + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + let response = try #require(await injector.response(for: id)) + #expect(try jsonResult(from: response) == ["decision": "decline"]) + } + + await injector.waitUntilIdle() + #expect(await injector.childCount() == 0) + #expect(await injector.responses().map(\.requestID) == ids) + } + + @Test func duplicateRequestIDRespondsAtMostOnce() async throws { + let invocationCount = Mutex(0) + let started = RegistryTestCountSignal(target: 1) + let suspension = RegistryTestSuspension() + let injector = await CodexAppServerTestServerRequestInjector { _ in + invocationCount.withLock { $0 += 1 } + await started.signal() + for await _ in suspension.stream {} + return .approval(.accept) + } + let id = CodexServerRequestID.string("duplicate") + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await started.wait() + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + suspension.finish() + _ = await injector.response(for: id) + await injector.waitUntilIdle() + + #expect(invocationCount.withLock { $0 } == 1) + #expect(await injector.responses().count == 1) + #expect(await injector.diagnostics().contains(.duplicateRequest(id))) + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await injector.waitUntilIdle() + #expect(invocationCount.withLock { $0 } == 2) + #expect(await injector.responses().count == 2) + } + + @Test func duplicateMalformedRequestIDRespondsAtMostOnceWhileReplyCommits() async { + let responseStarted = RegistryTestCountSignal(target: 1) + let responseGate = RegistryTestManualGate() + let responseCount = Mutex(0) + let diagnostics = Mutex<[ServerRequestRegistry.Diagnostic]>([]) + let connectionEventHub = ConnectionEventHub() + var connectionEventIterator = connectionEventHub.events().makeAsyncIterator() + let registry = ServerRequestRegistry( + connectionEventHub: connectionEventHub, + handler: { _ in + throw RegistryTestFailure.unexpectedResponse + }, + responder: { _, _ in + await responseStarted.signal() + await responseGate.wait() + responseCount.withLock { $0 += 1 } + }, + diagnosticHandler: { diagnostic in + diagnostics.withLock { $0.append(diagnostic) } + } + ) + let id = CodexServerRequestID.string("malformed-duplicate") + let method = "item/commandExecution/requestApproval" + let malformedParams = Data("{}".utf8) + + await registry.receive(id: id, method: method, params: malformedParams) + await responseStarted.wait() + await registry.receive(id: id, method: method, params: malformedParams) + await responseGate.release() + await registry.waitUntilIdle() + + #expect(responseCount.withLock { $0 } == 1) + guard case .warning(let decodeWarning) = await connectionEventIterator.next() else { + Issue.record("Expected the decode failure on the connection event stream.") + return + } + #expect(decodeWarning.method == method) + #expect(decodeWarning.details?.contains("malformed-duplicate") == true) + guard case .warning(let duplicateWarning) = await connectionEventIterator.next() else { + Issue.record("Expected the duplicate request on the connection event stream.") + return + } + #expect(duplicateWarning.message == "Received a duplicate server-request identifier.") + #expect(duplicateWarning.details == "requestId: malformed-duplicate") + #expect(diagnostics.withLock { values in + values.contains(.duplicateRequest(id)) + && values.contains { diagnostic in + if case .decodeFailed(id, let decodedMethod, _) = diagnostic { + return decodedMethod == method + } + return false + } + }) + } + + @Test func handlerThrowRespondsWithInternalErrorAndDiagnostic() async throws { + let injector = await CodexAppServerTestServerRequestInjector { _ in + throw RegistryTestFailure.handler + } + let id = CodexServerRequestID.integer(7) + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + guard let response = await injector.response(for: id) else { + Issue.record("Expected a handler failure response.") + return + } + guard case .error(let code, let message) = response else { + Issue.record("Expected a JSON-RPC internal error.") + return + } + #expect(code == -32603) + #expect(message.contains("Handler failed")) + #expect(await injector.diagnostics().contains { diagnostic in + if case .handlerFailed(id, let method, _) = diagnostic { + return method == "item/commandExecution/requestApproval" + } + return false + }) + #expect(await injector.childCount() == 0) + } + + @Test func closeTracksHandlerUntilCommittedResponseFinishes() async throws { + let responderStarted = RegistryTestCountSignal(target: 1) + let responderGate = RegistryTestManualGate() + let responderCancelled = RegistryTestThreadSafeSignal() + let closeCompleted = Mutex(false) + let registry = ServerRequestRegistry( + connectionEventHub: ConnectionEventHub(), + handler: { _ in .approval(.accept) }, + responder: { _, _ in + await responderStarted.signal() + await withTaskCancellationHandler { + await responderGate.wait() + } onCancel: { + responderCancelled.signal() + } + } + ) + + await registry.receive( + id: .string("committing-response"), + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await responderStarted.wait() + #expect(await registry.childCount() == 1) + + let closeTask = Task { + await registry.cancelAllAndWait() + closeCompleted.withLock { $0 = true } + } + await responderCancelled.wait() + + #expect(await registry.childCount() == 1) + #expect(closeCompleted.withLock { $0 } == false) + + await responderGate.release() + await closeTask.value + #expect(await registry.childCount() == 0) + #expect(closeCompleted.withLock { $0 }) + } + + @Test func resolvedNotificationCancelsAndAwaitsMatchingChildWithoutResponse() async throws { + let started = RegistryTestCountSignal(target: 1) + let suspension = RegistryTestSuspension() + let injector = await CodexAppServerTestServerRequestInjector { _ in + await started.signal() + for await _ in suspension.stream {} + return .approval(.accept) + } + let id = CodexServerRequestID.string("resolved") + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await started.wait() + #expect(await injector.childCount() == 1) + + let responseTask = Task { + await injector.response(for: id) + } + try await injector.resolve(id) + + #expect(await injector.childCount() == 0) + #expect(await injector.responses().isEmpty) + #expect(await responseTask.value == nil) + } + + @Test func closeRejectsNewWorkAndDrainsEveryChild() async throws { + let started = RegistryTestCountSignal(target: 2) + let suspension = RegistryTestSuspension() + let injector = await CodexAppServerTestServerRequestInjector { _ in + await started.signal() + for await _ in suspension.stream {} + return .approval(.accept) + } + + for id in [CodexServerRequestID.integer(1), .string("two")] { + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + } + await started.wait() + #expect(await injector.childCount() == 2) + + await injector.close() + + #expect(await injector.childCount() == 0) + #expect(await injector.responses().isEmpty) + let rejectedID = CodexServerRequestID.string("after-close") + await #expect(throws: CodexTransportFailure.self) { + try await injector.inject( + id: rejectedID, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + } + } + + @Test func handlerOwnedRegistryCloseSignalsWithoutAwaitingItself() async throws { + let injectorBox = RegistryTestInjectorBox() + let siblingStarted = RegistryTestCountSignal(target: 1) + let closeReturned = RegistryTestCountSignal(target: 1) + let siblingGate = RegistryTestManualGate() + let injector = await CodexAppServerTestServerRequestInjector { request in + guard case .commandExecutionApproval(let approval) = request else { + throw RegistryTestFailure.unexpectedResponse + } + if approval.itemID == "sibling" { + await siblingStarted.signal() + await siblingGate.wait() + return .approval(.accept) + } + await injectorBox.value()?.close() + await closeReturned.signal() + return .approval(.accept) + } + injectorBox.set(injector) + let id = CodexServerRequestID.string("self-close") + + try await injector.inject( + id: .string("sibling"), + method: "item/commandExecution/requestApproval", + params: commandApprovalParams(itemID: "sibling") + ) + await siblingStarted.wait() + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams(itemID: "closer") + ) + await closeReturned.wait() + #expect(await injector.childCount() == 1) + + await siblingGate.release() + await injector.waitUntilIdle() + await injector.close() + + #expect(await injector.childCount() == 0) + #expect(await injector.responses().isEmpty) + #expect(await injector.diagnostics().contains(.ownedTaskRequestedClose(id))) + } + + @Test func requestAcceptedBeforeHandlerOwnedCloseIsRejectedByClosingRegistry() async throws { + let injectorBox = RegistryTestInjectorBox() + let handlerStarted = RegistryTestCountSignal(target: 1) + let requestClose = RegistryTestManualGate() + let closeReturned = RegistryTestCountSignal(target: 1) + let invocationCount = Mutex(0) + let injector = await CodexAppServerTestServerRequestInjector { _ in + invocationCount.withLock { $0 += 1 } + await handlerStarted.signal() + await requestClose.wait() + await injectorBox.value()?.close() + await closeReturned.signal() + return .approval(.accept) + } + injectorBox.set(injector) + + try await injector.inject( + id: .string("close-owner"), + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await handlerStarted.wait() + + let deliveryGate = CodexAppServerTestGate() + await injector.holdNextInboundEventDelivery(at: deliveryGate) + let rejectedID = CodexServerRequestID.string("accepted-before-close") + try await injector.injectAccepted( + id: rejectedID, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await injector.waitUntilInboundEventDeliveryIsHeld() + + await requestClose.release() + await closeReturned.wait() + await injector.waitUntilInjectedRequestsAreDelivered() + await injector.waitUntilIdle() + + #expect(invocationCount.withLock { $0 } == 1) + #expect(await injector.responses().isEmpty) + #expect(await injector.diagnostics().contains( + .rejectedWhileClosing( + rejectedID, + method: "item/commandExecution/requestApproval" + ) + )) + } + + @Test func inheritedContextCannotClaimAReusedRequestIDAfterItsHandlerCompletes() async throws { + let injectorBox = RegistryTestInjectorBox() + let invocationCount = Mutex(0) + let inheritedChildStarted = RegistryTestCountSignal(target: 1) + let inheritedCloseGate = RegistryTestManualGate() + let inheritedCloseReturned = RegistryTestCountSignal(target: 1) + let reusedRequestStarted = RegistryTestCountSignal(target: 1) + let injector = await CodexAppServerTestServerRequestInjector { _ in + let invocation = invocationCount.withLock { count in + count += 1 + return count + } + if invocation == 1 { + Task { + await inheritedChildStarted.signal() + await inheritedCloseGate.wait() + await injectorBox.value()?.close() + await inheritedCloseReturned.signal() + } + return .approval(.accept) + } + + await reusedRequestStarted.signal() + try await Task.sleep(for: .seconds(3_600)) + return .approval(.accept) + } + injectorBox.set(injector) + let id = CodexServerRequestID.string("reused") + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await inheritedChildStarted.wait() + _ = await injector.response(for: id) + await injector.waitUntilIdle() + + try await injector.inject( + id: id, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + await reusedRequestStarted.wait() + #expect(await injector.childCount() == 1) + + await inheritedCloseGate.release() + await inheritedCloseReturned.wait() + + #expect(await injector.childCount() == 0) + #expect(await injector.responses().count == 1) + #expect(await injector.diagnostics().contains(.ownedTaskRequestedClose(id)) == false) + + let rejectedID = CodexServerRequestID.string("after-inherited-close") + await #expect(throws: CodexTransportFailure.self) { + try await injector.inject( + id: rejectedID, + method: "item/commandExecution/requestApproval", + params: commandApprovalParams() + ) + } + } + + @Test func processTransportRoutesResolvedNotificationAndSuppressesWireResponse() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executableURL = rootURL.appendingPathComponent("fake-app-server") + let startedURL = rootURL.appendingPathComponent("handler-started") + let responseURL = rootURL.appendingPathComponent("response.json") + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootURL) + } + + try """ + #!/bin/sh + printf '%s\\n' '{"id":"request-string","method":"item/commandExecution/requestApproval","params":{"threadId":"thread","turnId":"turn","itemId":"item","startedAtMs":1}}' + while [ ! -f "$STARTED_PATH" ]; do :; done + printf '%s\\n' '{"method":"serverRequest/resolved","params":{"threadId":"thread","requestId":"request-string"}}' + if IFS= read -r line; then + printf '%s\\n' "$line" > "$RESPONSE_PATH" + fi + """ + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + + let cancellationObserved = RegistryTestCountSignal(target: 1) + let transport = try AppServerProcessTransport( + configuration: .init( + executable: executableURL.path, + arguments: [], + environment: [ + "STARTED_PATH": startedURL.path, + "RESPONSE_PATH": responseURL.path, + ], + codexHomeURL: rootURL.appendingPathComponent("codex-home", isDirectory: true) + ), + connectionEventHub: ConnectionEventHub() + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + processTerminationToken: transport.processTerminationToken, + handler: { _ in + try Data().write(to: startedURL) + do { + try await Task.sleep(for: .seconds(3_600)) + return .approval(.accept) + } catch { + await cancellationObserved.signal() + throw error + } + } + ) + + await cancellationObserved.wait() + #expect(FileManager.default.fileExists(atPath: responseURL.path) == false) + await harness.close() + #expect(FileManager.default.fileExists(atPath: responseURL.path) == false) + } + + @Test func handlerOwnedConnectionCloseReturnsThenSupervisorFinishesFullClose() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executableURL = rootURL.appendingPathComponent("fake-app-server") + let readyURL = rootURL.appendingPathComponent("ready") + let responseURL = rootURL.appendingPathComponent("response.json") + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootURL) + } + + try """ + #!/bin/sh + while [ ! -f "$READY_PATH" ]; do :; done + printf '%s\\n' '{"id":"self-close","method":"item/commandExecution/requestApproval","params":{"threadId":"thread","turnId":"turn","itemId":"item","startedAtMs":1}}' + if IFS= read -r line; then + printf '%s\\n' "$line" > "$RESPONSE_PATH" + fi + """ + .write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + + let leaseBox = RegistryTestConnectionLeaseBox() + let handlerReturned = RegistryTestCountSignal(target: 1) + let transport = try AppServerProcessTransport( + configuration: .init( + executable: executableURL.path, + arguments: [], + environment: [ + "READY_PATH": readyURL.path, + "RESPONSE_PATH": responseURL.path, + ], + codexHomeURL: rootURL.appendingPathComponent("codex-home", isDirectory: true) + ), + connectionEventHub: ConnectionEventHub() + ) + let harness = await CodexAppServerTestConnectionHarness.start( + transport: transport, + processTerminationToken: transport.processTerminationToken, + handler: { _ in + await leaseBox.value()?.closeConnection() + await handlerReturned.signal() + return .approval(.accept) + } + ) + leaseBox.set(harness.lease) + try Data().write(to: readyURL) + + await handlerReturned.wait() + #expect(FileManager.default.fileExists(atPath: responseURL.path) == false) + + await harness.supervisor.waitUntilClosed() + #expect(await harness.supervisor.serverRequestChildCount() == 0) + #expect(FileManager.default.fileExists(atPath: responseURL.path) == false) + } + + private func commandApprovalParams(itemID: String = "item") -> Data { + Data( + #"{"threadId":"thread","turnId":"turn","itemId":"\#(itemID)","startedAtMs":1}"#.utf8 + ) + } + + private func jsonResult( + from response: CodexServerRequestResponse + ) throws -> [String: String] { + guard case .result(let data) = response else { + throw RegistryTestFailure.unexpectedResponse + } + return try JSONDecoder().decode([String: String].self, from: data) + } +} + +private enum RegistryTestFailure: Error { + case handler + case unexpectedResponse +} + +private actor RegistryTestCountSignal { + private let target: Int + private var count = 0 + private var waiters: [CheckedContinuation] = [] + + init(target: Int) { + self.target = target + } + + func signal() { + count += 1 + guard count >= target else { + return + } + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } + + func wait() async { + guard count < target else { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private actor RegistryTestManualGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard isOpen == false else { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + isOpen = true + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } +} + +private final class RegistryTestThreadSafeSignal: Sendable { + private enum State { + case pending([CheckedContinuation]) + case signalled + } + + private let state = Mutex(.pending([])) + + func signal() { + let waiters = state.withLock { state in + switch state { + case .pending(let waiters): + state = .signalled + return waiters + case .signalled: + return [] + } + } + for waiter in waiters { + waiter.resume() + } + } + + func wait() async { + await withCheckedContinuation { continuation in + let isSignalled = state.withLock { state in + switch state { + case .pending(var waiters): + waiters.append(continuation) + state = .pending(waiters) + return false + case .signalled: + return true + } + } + if isSignalled { + continuation.resume() + } + } + } +} + +private final class RegistryTestSuspension: Sendable { + let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + func finish() { + continuation.finish() + } + + deinit { + continuation.finish() + } +} + +private final class RegistryTestInjectorBox: Sendable { + private let storage = Mutex(nil) + + func set(_ injector: CodexAppServerTestServerRequestInjector) { + storage.withLock { $0 = injector } + } + + func value() -> CodexAppServerTestServerRequestInjector? { + storage.withLock { $0 } + } +} + +private final class RegistryTestConnectionLeaseBox: Sendable { + private let storage = Mutex(nil) + + func set(_ lease: AppServerConnectionLease) { + storage.withLock { $0 = lease } + } + + func value() -> AppServerConnectionLease? { + storage.withLock { $0 } + } +} diff --git a/Tests/CodexAppServerKitTests/TerminalLifecycleProofTests.swift b/Tests/CodexAppServerKitTests/TerminalLifecycleProofTests.swift new file mode 100644 index 00000000..9f8eb8fa --- /dev/null +++ b/Tests/CodexAppServerKitTests/TerminalLifecycleProofTests.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit +import CodexAppServerKitTesting + +@Suite("Terminal lifecycle proof") +struct TerminalLifecycleProofTests { + @Test func concurrentPublicHandleDoubleCloseJoinsOneFullClose() async { + let transport = CloseCountingTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let server = harness.server + let thread = makeThread(harness: harness, id: "thread-close") + let review = await makeReview( + thread: thread, + turnID: "turn-close" + ) + let barrier = CloseCallerBarrier(count: 6) + + async let firstRootClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await server.close() + } + async let secondRootClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await server.close() + } + async let firstThreadClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await thread.closeConnection() + } + async let secondThreadClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await thread.closeConnection() + } + async let firstReviewClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await review.closeConnection() + } + async let secondReviewClose = closeAndSnapshot( + barrier: barrier, + transport: transport + ) { + await review.closeConnection() + } + + await transport.waitUntilCloseStarts() + #expect(await transport.snapshot().beginCloseCount == 1) + await transport.releaseClose() + + let returnedSnapshots = await ( + firstRootClose, + secondRootClose, + firstThreadClose, + secondThreadClose, + firstReviewClose, + secondReviewClose + ) + for snapshot in [ + returnedSnapshots.0, + returnedSnapshots.1, + returnedSnapshots.2, + returnedSnapshots.3, + returnedSnapshots.4, + returnedSnapshots.5, + ] { + #expect(snapshot.reapProcessCount == 1) + } + + let snapshot = await transport.snapshot() + #expect(snapshot.beginCloseCount == 1) + #expect(snapshot.finishPendingResponsesCount == 1) + #expect(snapshot.waitForProcessExitCount == 1) + #expect(snapshot.waitUntilClosedCount == 1) + #expect(snapshot.reapProcessCount == 1) + #expect(await harness.supervisor.terminationForTesting() == .closedByCaller) + } + + @Test func lastTerminalReviewHandleDropReleasesGenerationState() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = makeThread(harness: harness, id: "thread-drop") + var first: CodexReviewSession? = await makeReview( + thread: thread, + turnID: "turn-drop" + ) + var last = first + let weakState = WeakReference(first?.response.turn.state) + let outcome = CodexTurnOutcome.completed(.init(turnID: "turn-drop")) + + await harness.turnReplayStore.finish(outcome) + + #expect(try await first?.collect() == outcome) + #expect(await harness.turnReplayStore.snapshotForTesting().activeGenerationCount == 0) + first = nil + #expect(weakState.value != nil) + #expect(try await last?.collect() == outcome) + last = nil + #expect(weakState.value == nil) + + await harness.close() + } + + @Test func terminalReviewAndResponseHandlesReplayRepeatedLateCollects() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = makeThread(harness: harness, id: "thread-late-collect") + let review = await makeReview( + thread: thread, + turnID: "turn-late-collect" + ) + let outcome = CodexTurnOutcome.completed(.init(turnID: "turn-late-collect")) + + await harness.turnReplayStore.finish(outcome) + + #expect(try await review.collect() == outcome) + #expect(try await review.collect() == outcome) + #expect(try await review.response.collect() == outcome) + #expect(try await review.response.collect() == outcome) + + await harness.close() + } + + @Test func reviewSessionReadsOnlyAnAlreadyCommittedTerminalOutcome() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = makeThread(harness: harness, id: "thread-known-terminal") + let review = await makeReview( + thread: thread, + turnID: "turn-known-terminal" + ) + + #expect(try await review.terminalOutcomeIfKnown() == nil) + + let outcome = CodexTurnOutcome.completed(.init(turnID: "turn-known-terminal")) + await harness.turnReplayStore.finish(outcome) + + #expect(try await review.terminalOutcomeIfKnown() == outcome) + #expect(try await review.terminalOutcomeIfKnown() == outcome) + + await harness.close() + } + + @Test func knownTerminalReadSurfacesConnectionTerminationWithoutSynthesizingOutcome() async { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + let thread = makeThread(harness: harness, id: "thread-known-termination") + let review = await makeReview( + thread: thread, + turnID: "turn-known-termination" + ) + + await harness.close() + + do { + _ = try await review.terminalOutcomeIfKnown() + Issue.record("Expected the committed connection termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(.closedByCaller)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func nonterminalAndHistoricalTerminalStatusesRemainTypedInvalidOutcomes() async throws { + let aliases = ["success", "succeeded", "cancelled", "aborted", "started", "running"] + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + + for alias in aliases { + let turnID = CodexTurnID(rawValue: "turn-alias-\(alias)") + let state = await harness.turnReplayStore.restoreGeneration( + turnID: turnID, + initialSnapshot: .init(id: turnID, state: .inProgress), + connectionLease: harness.lease + ) + let turn = CodexTurn( + id: turnID, + threadID: "thread-alias", + client: harness.client, + router: harness.router, + turnReplayStore: harness.turnReplayStore, + state: state + ) + + try await transport.emitServerNotificationJSON( + method: "turn/completed", + json: #"{"threadId":"thread-alias","turn":{"id":"\#(turnID.rawValue)","status":"\#(alias)","items":[]}}"# + ) + + guard case .invalidTerminalStatus(let rawStatus, let error, let response) = + try await turn.result() + else { + Issue.record("Expected \(alias) to remain an invalid terminal status.") + continue + } + let expectedRawStatus = ["started", "running"].contains(alias) ? "inProgress" : alias + #expect(rawStatus == expectedRawStatus) + #expect(error == nil) + #expect(response.turnID == turnID) + } + + await harness.close() + } + + private func makeThread( + harness: CodexAppServerTestConnectionHarness, + id: CodexThreadID + ) -> CodexThread { + CodexThread( + id: id, + client: harness.client, + router: harness.router, + connectionLease: harness.lease + ) + } + + private func makeReview( + thread: CodexThread, + turnID: CodexTurnID + ) async -> CodexReviewSession { + await thread.reviewSession( + .init(threadID: thread.id, turnID: turnID), + initialTurn: .init( + id: turnID, + state: .inProgress, + itemsLoadState: .notLoaded + ) + ) + } +} + +private func closeAndSnapshot( + barrier: CloseCallerBarrier, + transport: CloseCountingTransport, + operation: @escaping @Sendable () async -> Void +) async -> CloseCountingTransport.Snapshot { + await barrier.arriveAndWait() + await operation() + return await transport.snapshot() +} + +private actor CloseCallerBarrier { + private let target: Int + private var arrivalCount = 0 + private var waiters: [CheckedContinuation] = [] + + init(count: Int) { + precondition(count > 0) + self.target = count + } + + func arriveAndWait() async { + arrivalCount += 1 + if arrivalCount == target { + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + return + } + precondition(arrivalCount < target) + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private actor CloseCountingTransport: JSONRPC.Transport { + struct Snapshot: Equatable, Sendable { + var beginCloseCount = 0 + var finishPendingResponsesCount = 0 + var waitForProcessExitCount = 0 + var waitUntilClosedCount = 0 + var reapProcessCount = 0 + } + + nonisolated let connectionEventHub = ConnectionEventHub() + private let inboundClose = CodexAppServerTestGate() + private let closeStarted = CodexAppServerTestGate() + private let closeRelease = CodexAppServerTestGate() + private var counts = Snapshot() + + func send( + _ request: JSONRPC.Request, + acceptWrite: @Sendable () throws -> Void + ) async throws -> Data { + throw JSONRPC.Error.closed + } + + func notify(_ notification: JSONRPC.Notification) async throws { + throw JSONRPC.Error.closed + } + + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? { + await inboundClose.waitIgnoringCancellation() + return nil + } + + func respond( + to requestID: CodexServerRequestID, + with response: CodexServerRequestResponse + ) async throws { + throw JSONRPC.Error.closed + } + + func beginClose() async -> JSONRPC.ProcessExitObservation? { + counts.beginCloseCount += 1 + await closeStarted.open() + await closeRelease.waitIgnoringCancellation() + await inboundClose.open() + return nil + } + + func finishPendingResponsesAfterInboundDrain(_ failure: CodexTransportFailure) async { + counts.finishPendingResponsesCount += 1 + } + + func waitForProcessExit() async -> JSONRPC.ProcessExitObservation { + counts.waitForProcessExitCount += 1 + return .unavailable + } + + func waitUntilClosed() async { + counts.waitUntilClosedCount += 1 + } + + func reapProcess() async { + counts.reapProcessCount += 1 + } + + func waitUntilCloseStarts() async { + await closeStarted.waitIgnoringCancellation() + } + + func releaseClose() async { + await closeRelease.open() + } + + func snapshot() -> Snapshot { + counts + } +} + +private final class WeakReference { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} diff --git a/Tests/CodexAppServerKitTests/ThreadEventHubTests.swift b/Tests/CodexAppServerKitTests/ThreadEventHubTests.swift new file mode 100644 index 00000000..eee1401c --- /dev/null +++ b/Tests/CodexAppServerKitTests/ThreadEventHubTests.swift @@ -0,0 +1,1138 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit + +@Suite("Thread event hub") +struct ThreadEventHubTests { + @Test func adoptedGenerationDoesNotClaimIdentityOnlySnapshotCompleteness() async throws { + let hub = ThreadEventHub() + + hub.beginGeneration(for: "thread-1", including: "turn-1") + + var iterator = hub.events(for: "thread-1").makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + } + + @Test func seededCurrentSnapshotKeepsLaterItemUpdatesAsLiveEvents() async throws { + let hub = ThreadEventHub() + let initial = messageItem(id: "message", text: "Initial") + let updated = messageItem(id: "message", text: "Updated") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + + hub.seedCurrentTurnSnapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .full, + items: [initial] + ), for: "thread-1") + try hub.route( + .itemCompleted(updated, turnID: "turn-1"), + for: "thread-1" + ) + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .full, + items: [initial] + ))) + #expect(try await iterator.next() == .itemCompleted(updated, turnID: "turn-1")) + events.cancel() + } + + @Test func historicalSnapshotYieldsToADifferentObservedTurn() async throws { + let hub = ThreadEventHub() + let historical = messageItem(id: "historical", text: "Historical") + let live = messageItem(id: "live", text: "Live") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + + hub.seedCurrentTurnSnapshot(.init( + id: "turn-history", + state: .inProgress, + itemsLoadState: .full, + items: [historical] + ), for: "thread-1") + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-history", + state: .inProgress, + itemsLoadState: .full, + items: [historical] + ))) + + try hub.route(.itemStarted(live, turnID: "turn-live"), for: "thread-1") + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-live", + state: .inProgress, + itemsLoadState: .notLoaded, + items: [live] + ))) + events.cancel() + } + + @Test func newerHistoricalSnapshotReplacesThePriorHistoricalGeneration() async throws { + let hub = ThreadEventHub() + let first = messageItem(id: "first", text: "First") + let second = messageItem(id: "second", text: "Second") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + + hub.seedCurrentTurnSnapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [first] + ), for: "thread-1") + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [first] + ))) + + hub.seedCurrentTurnSnapshot(.init( + id: "turn-2", + state: .inProgress, + itemsLoadState: .full, + items: [second] + ), for: "thread-1") + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-2", + state: .inProgress, + itemsLoadState: .full, + items: [second] + ))) + events.cancel() + } + + @Test func reviewStartDispositionKeepsDetachedSourceEventsOnTheirSource() throws { + let hub = ThreadEventHub() + let review = try hub.registerCheckpoint( + for: "thread-source", + operation: .reviewStart(delivery: .detached) + ) + let resume = try hub.registerCheckpoint(for: "thread-resume") + + hub.activate(review) + hub.activate(resume) + + #expect(hub.turnStartDisposition(for: "thread-source") == .route) + #expect(hub.turnStartDisposition(for: "thread-detached") == .deferUntilOwned) + #expect(hub.turnStartDisposition(for: "thread-resume") == .route) + + hub.discard(review) + hub.discard(resume) + } + + @Test func detachedReviewResponseMovesTheCheckpointWithoutResettingItsSourceThread() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-source", including: "turn-source") + let sourceEvents = hub.events(for: "thread-source") + var sourceIterator = sourceEvents.makeAsyncIterator() + let checkpoint = try hub.registerCheckpoint( + for: "thread-source", + operation: .reviewStart(delivery: .detached) + ) + hub.activate(checkpoint) + try hub.route( + .statusChanged(.active(activeFlags: [.waitingOnApproval])), + for: "thread-source" + ) + + try hub.resolveReviewStart( + checkpoint, + eventThreadID: "thread-review", + responseSnapshot: .init(id: "turn-review", state: .inProgress) + ) + + let source = hub.snapshotForTesting(threadID: "thread-source") + #expect(source.currentTurnID == "turn-source") + #expect(source.hasActiveCheckpoint == false) + let review = hub.snapshotForTesting(threadID: "thread-review") + #expect(review.currentTurnID == "turn-review") + #expect(review.hasActiveCheckpoint == false) + #expect(review.pendingCheckpointCount == 0) + #expect(try await sourceIterator.next() == .snapshot(.init( + id: "turn-source", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + #expect(try await sourceIterator.next() == .statusChanged( + .active(activeFlags: [.waitingOnApproval]) + )) + sourceEvents.cancel() + } + + @Test func detachedReviewResponseRequiresAPreviouslyUnseenEventThread() throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-review", including: "turn-existing") + let checkpoint = try hub.registerCheckpoint( + for: "thread-source", + operation: .reviewStart(delivery: .detached) + ) + hub.activate(checkpoint) + + #expect(throws: CodexTransportFailure.contractViolation( + message: "A detached review must use a previously unseen event thread." + )) { + try hub.resolveReviewStart( + checkpoint, + eventThreadID: "thread-review", + responseSnapshot: .init(id: "turn-review", state: .inProgress) + ) + } + #expect(hub.snapshotForTesting(threadID: "thread-review").currentTurnID == "turn-existing") + #expect(hub.snapshotForTesting(threadID: "thread-source").hasActiveCheckpoint) + + hub.discard(checkpoint) + } + + @Test func concurrentDetachedReviewResponsesCannotClaimTheSameEventThread() throws { + let hub = ThreadEventHub() + let first = try hub.registerCheckpoint( + for: "thread-source-first", + operation: .reviewStart(delivery: .detached) + ) + let second = try hub.registerCheckpoint( + for: "thread-source-second", + operation: .reviewStart(delivery: .detached) + ) + hub.activate(first) + hub.activate(second) + + try hub.resolveReviewStart( + second, + eventThreadID: "thread-review", + responseSnapshot: .init(id: "turn-second", state: .inProgress) + ) + #expect(throws: CodexTransportFailure.contractViolation( + message: "A detached review must use a previously unseen event thread." + )) { + try hub.resolveReviewStart( + first, + eventThreadID: "thread-review", + responseSnapshot: .init(id: "turn-first", state: .inProgress) + ) + } + #expect(hub.snapshotForTesting(threadID: "thread-review").currentTurnID == "turn-second") + + hub.discard(first) + } + + @Test func laterPublicationCompactsAcrossAnOvertakenGenerationCommit() async throws { + let hub = ThreadEventHub() + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + try hub.route(.turnStarted("turn-1"), for: "thread-1") + let gate = PublicationGate() + + let commit = Task.detached { + hub.commitForTesting(checkpoint) { + gate.blockPublication() + } + } + #expect(gate.waitUntilBlocked()) + try hub.route(.statusChanged(.active(activeFlags: [])), for: "thread-1") + gate.releasePublication() + await commit.value + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress + ))) + #expect(try await iterator.next() == .statusChanged(.active(activeFlags: []))) + #expect(hub.snapshotForTesting(threadID: "thread-1").overflowCount == 1) + events.cancel() + } + + @Test func snapshotDoesNotWaitForBlockedSubscriptionPreparation() async throws { + let hub = ThreadEventHub() + let gate = PublicationGate() + let snapshotCompleted = DispatchSemaphore(value: 0) + let subscription = Task.detached { + hub.eventsForTesting(for: "thread-1") { + gate.blockPublication() + } + } + defer { gate.releasePublication() } + try #require(gate.waitUntilBlocked()) + + let snapshotTask = Task.detached { + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + snapshotCompleted.signal() + return snapshot + } + let completedWhilePreparationWasBlocked = await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume( + returning: snapshotCompleted.wait(timeout: .now() + 5) == .success + ) + } + } + gate.releasePublication() + + let events = await subscription.value + let snapshot = await snapshotTask.value + #expect(completedWhilePreparationWasBlocked) + #expect(snapshot.subscriberCount == 0) + #expect(snapshot.threadStateCount == 0) + events.cancel() + } + + @Test func blockedSubscriptionPreparationDoesNotDelayConcurrentPublication() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let existing = CodexThreadEvent.unknown(.init( + method: "thread/existing", + params: Data() + )) + let concurrent = CodexThreadEvent.unknown(.init( + method: "thread/concurrent", + params: Data() + )) + try hub.route(existing, for: "thread-1") + let gate = PublicationGate() + let publicationCompleted = DispatchSemaphore(value: 0) + let subscription = Task.detached { + hub.eventsForTesting(for: "thread-1") { + gate.blockPublication() + } + } + defer { gate.releasePublication() } + try #require(gate.waitUntilBlocked()) + + let publication = Task.detached { + defer { publicationCompleted.signal() } + return try hub.route(concurrent, for: "thread-1") + } + let completedWhilePreparationWasBlocked = await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume( + returning: publicationCompleted.wait(timeout: .now() + 5) == .success + ) + } + } + gate.releasePublication() + + let events = await subscription.value + _ = try await publication.value + #expect(completedWhilePreparationWasBlocked) + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + #expect(try await iterator.next() == existing) + #expect(try await iterator.next() == concurrent) + events.cancel() + } + + @Test func serializerSchedulingKeepsOnlyTheAcceptedAttempt() async throws { + let hub = ThreadEventHub() + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let first = try hub.registerCheckpoint(for: "thread-1") + let second = try hub.registerCheckpoint(for: "thread-1") + + hub.activate(first) + hub.activate(first) + try hub.route(.turnStarted("turn-rejected"), for: "thread-1") + hub.reject(first) + + #expect(hub.snapshotForTesting(threadID: "thread-1").hasActiveCheckpoint == false) + #expect(hub.snapshotForTesting(threadID: "thread-1").pendingCheckpointCount == 2) + + hub.activate(first) + try hub.route(.turnStarted("turn-1"), for: "thread-1") + hub.commit(first) + hub.discard(first) + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress + ))) + #expect(hub.snapshotForTesting(threadID: "thread-1").pendingCheckpointCount == 1) + + hub.activate(second) + try hub.route(.turnStarted("turn-2"), for: "thread-1") + hub.commit(second) + hub.discard(second) + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-2", + state: .inProgress + ))) + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + #expect(snapshot.pendingCheckpointCount == 0) + #expect(snapshot.hasActiveCheckpoint == false) + #expect(snapshot.currentTurnID == "turn-2") + events.cancel() + } + + @Test func responseSnapshotMergesAfterAnEarlyTerminal() async throws { + let hub = ThreadEventHub() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + let outcome = CodexTurnOutcome.completed(.init(turnID: "turn-1")) + try hub.route(.terminal(outcome), for: "thread-1") + + try hub.seed( + .init(id: "turn-1", state: .inProgress), + at: checkpoint + ) + hub.commit(checkpoint) + + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init(id: "turn-1", state: .completed))) + #expect(try await iterator.next() == .terminal(.completed(.init( + turnID: "turn-1", + transcript: .init(), + transcriptItemsLoadState: .full + )))) + events.cancel() + } + + @Test func provisionalResumeSnapshotAdoptsAnEarlierCanonicalEventIdentity() async throws { + let hub = ThreadEventHub() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + try hub.route( + .itemStarted(messageItem(id: "live-item", text: "Live"), turnID: "turn-live"), + for: "thread-1" + ) + + hub.seedProvisionalResumeSnapshot( + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [messageItem(id: "response-item", text: "Response")] + ), + at: checkpoint + ) + hub.commit(checkpoint) + + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let event = try #require(try await iterator.next()) + guard case .snapshot(let snapshot) = event else { + Issue.record("Expected a compact snapshot.") + return + } + #expect(snapshot.id == "turn-live") + #expect(snapshot.items.map(\.id) == ["response-item", "live-item"]) + events.cancel() + } + + @Test func provisionalResumeSnapshotWaitsForALaterCanonicalEventIdentity() async throws { + let hub = ThreadEventHub() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + hub.seedProvisionalResumeSnapshot( + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [messageItem(id: "response-item", text: "Response")] + ), + at: checkpoint + ) + hub.commit(checkpoint) + + let committed = hub.snapshotForTesting(threadID: "thread-1") + #expect(committed.currentTurnID == nil) + #expect(committed.currentEventCount == 0) + + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + try hub.route( + .itemStarted(messageItem(id: "live-item", text: "Live"), turnID: "turn-live"), + for: "thread-1" + ) + + let event = try #require(try await iterator.next()) + guard case .snapshot(let snapshot) = event else { + Issue.record("Expected a compact snapshot.") + return + } + #expect(snapshot.id == "turn-live") + #expect(snapshot.items.map(\.id) == ["response-item", "live-item"]) + events.cancel() + } + + @Test func persistedReviewIdentityPromotesACommittedProvisionalResumeSnapshot() async throws { + let hub = ThreadEventHub() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + hub.seedProvisionalResumeSnapshot( + .init( + id: "rollout-synthesized-turn", + state: .inProgress, + items: [messageItem(id: "response-item", text: "Response")] + ), + at: checkpoint + ) + hub.commit(checkpoint) + + hub.beginGeneration(for: "thread-1", including: "turn-persisted") + + let committed = hub.snapshotForTesting(threadID: "thread-1") + #expect(committed.currentTurnID == "turn-persisted") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let event = try #require(try await iterator.next()) + guard case .snapshot(let snapshot) = event else { + Issue.record("Expected a compact snapshot.") + return + } + #expect(snapshot.id == "turn-persisted") + #expect(snapshot.items.map(\.id) == ["response-item"]) + events.cancel() + } + + @Test func sparseTerminalPreservesPartialSnapshotCompletenessForLateSubscribers() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let seededItem = messageItem(id: "seeded", text: "Seeded summary") + try hub.route(.snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [seededItem] + )), for: "thread-1") + let observedItem = messageItem(id: "observed", text: "Complete live item") + try hub.route( + .itemCompleted(observedItem, turnID: "turn-1"), + for: "thread-1" + ) + let terminalItem = messageItem(id: "observed", text: "Terminal summary") + let outcome = CodexTurnOutcome.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalItem]), + transcriptItemsLoadState: .summary + )) + try hub.route(.terminal(outcome), for: "thread-1") + + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let event = try #require(try await iterator.next()) + guard case .snapshot(let snapshot) = event else { + Issue.record("Expected a compact terminal snapshot.") + return + } + #expect(snapshot.itemsLoadState == .summary) + #expect(snapshot.items == [seededItem, observedItem]) + #expect(try await iterator.next() == .terminal(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [seededItem, observedItem]), + transcriptItemsLoadState: .summary + )))) + events.cancel() + } + + @Test func sparseTerminalReconcilesCompositeItemIdentityForLateSubscribers() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let entered = CodexThreadItem( + id: "review-marker", + kind: .enteredReviewMode, + content: .log("Entered") + ) + let staleExit = CodexThreadItem( + id: "review-marker", + kind: .exitedReviewMode, + content: .log("Stale") + ) + try hub.route(.snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [entered, staleExit] + )), for: "thread-1") + let observedEntered = CodexThreadItem( + id: "review-marker", + kind: .enteredReviewMode, + content: .log("Entered live") + ) + try hub.route( + .itemCompleted(observedEntered, turnID: "turn-1"), + for: "thread-1" + ) + let terminalExit = CodexThreadItem( + id: "review-marker", + kind: .exitedReviewMode, + content: .log("Final review") + ) + + try hub.route(.terminal(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalExit]), + transcriptItemsLoadState: .summary + ))), for: "thread-1") + + var iterator = hub.events(for: "thread-1").makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .summary, + items: [observedEntered, terminalExit] + ))) + #expect(try await iterator.next() == .terminal(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [observedEntered, terminalExit]), + transcriptItemsLoadState: .summary + )))) + } + + @Test func terminalIsExactlyOnceNonDroppableAndDoesNotFinishTheThread() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + let outcome = CodexTurnOutcome.completed(.init(turnID: "turn-1")) + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + try hub.route(.terminal(outcome), for: "thread-1") + #expect(try hub.route(.terminal(outcome), for: "thread-1") == 0) + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .notLoaded + ))) + #expect(try await iterator.next() == .terminal(outcome)) + + try hub.route(.statusChanged(.idle), for: "thread-1") + #expect(try await iterator.next() == .statusChanged(.idle)) + + do { + try hub.route( + .terminal(.interrupted(.init(turnID: "turn-1"))), + for: "thread-1" + ) + Issue.record("Expected a conflicting terminal outcome to fail.") + } catch let error as CodexTransportFailure { + #expect(error == .contractViolation( + message: "Turn turn-1 reported conflicting terminal outcomes." + )) + } + + try hub.route(.closed, for: "thread-1") + var remaining: [CodexThreadEvent] = [] + while let event = try await iterator.next() { + remaining.append(event) + } + #expect(remaining.contains(.terminal(outcome)) == false) + #expect(remaining.last == .closed) + + var late = hub.events(for: "thread-1").makeAsyncIterator() + var lateEvents: [CodexThreadEvent] = [] + while let event = try await late.next() { + lateEvents.append(event) + } + #expect(lateEvents.first == .snapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .notLoaded + ))) + #expect(lateEvents.filter { $0 == .terminal(outcome) }.count == 1) + #expect(lateEvents.last == .closed) + } + + @Test func terminalReplayHasExactCausalOrderForSlowAndLateSubscribers() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let slow = hub.events(for: "thread-1") + let usage = CodexTokenUsage(inputTokens: 1, outputTokens: 2, totalTokens: 3) + let wireOutcome = CodexTurnOutcome.completed(.init(turnID: "turn-1")) + let outcome = CodexTurnOutcome.completed(.init( + turnID: "turn-1", + usage: usage + )) + let postStatus = CodexThreadStatus.idle + let postUnknown = CodexThreadEvent.unknown(.init( + method: "post-terminal", + params: Data(), + threadID: "thread-1", + turnID: "turn-1" + )) + + try hub.route(.statusChanged(.active(activeFlags: [])), for: "thread-1") + try hub.route(.unknown(.init( + method: "pre-terminal", + params: Data(), + threadID: "thread-1", + turnID: "turn-1" + )), for: "thread-1") + try hub.route(.tokenUsageUpdated(usage, turnID: "turn-1"), for: "thread-1") + try hub.route(.terminal(wireOutcome), for: "thread-1") + try hub.route(.statusChanged(postStatus), for: "thread-1") + try hub.route(postUnknown, for: "thread-1") + try hub.route(.closed, for: "thread-1") + + let expected: [CodexThreadEvent] = [ + .snapshot(.init( + id: "turn-1", + state: .completed, + itemsLoadState: .notLoaded + )), + .terminal(outcome), + .statusChanged(postStatus), + postUnknown, + .closed, + ] + #expect(try await collect(from: slow) == expected) + #expect(try await collect(from: hub.events(for: "thread-1")) == expected) + } + + @Test func overflowCompactsToSnapshotBeforeTheBoundedNewestSuffix() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + + for index in 0...256 { + try hub.route( + .unknown(.init( + method: "probe/\(index)", + params: Data(), + threadID: "thread-1", + turnID: "turn-1" + )), + for: "thread-1" + ) + } + + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + #expect(snapshot.overflowCount == 1) + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + + var methods: [String] = [] + for _ in 0..<253 { + guard case .unknown(let raw) = try #require(try await iterator.next()) else { + Issue.record("Expected compacted unknown diagnostic suffix.") + break + } + methods.append(raw.method) + } + #expect(methods.first == "probe/4") + #expect(methods.last == "probe/256") + events.cancel() + } + + @Test func detachedAdoptionUsesTheMatchingBoundedProvisionalGeneration() async throws { + let hub = ThreadEventHub() + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + try hub.route(.statusChanged(.active(activeFlags: [])), for: "thread-1") + try hub.route(.turnStarted("turn-1"), for: "thread-1") + + hub.beginGeneration(for: "thread-1", including: "turn-1") + hub.commit(checkpoint) + + var iterator = hub.events(for: "thread-1").makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress + ))) + #expect(try await iterator.next() == .statusChanged(.active(activeFlags: []))) + #expect(hub.snapshotForTesting(threadID: "thread-1").pendingCheckpointCount == 0) + } + + @Test func lateSubscriberReplaysBoundedIncrementalsWithinTheCurrentGeneration() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let item = CodexThreadItem( + id: "message-1", + kind: .agentMessage, + content: .message(.init(id: "message-1", role: .assistant, text: "")) + ) + let first = CodexMessageDelta(text: "First", itemID: "message-1") + let second = CodexMessageDelta(text: "Second", itemID: "message-1") + try hub.route(.itemStarted(item, turnID: "turn-1"), for: "thread-1") + try hub.route(.messageDelta(first, turnID: "turn-1"), for: "thread-1") + try hub.route(.messageDelta(second, turnID: "turn-1"), for: "thread-1") + + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded, + items: [item] + ))) + #expect(try await iterator.next() == .messageDelta(first, turnID: "turn-1")) + #expect(try await iterator.next() == .messageDelta(second, turnID: "turn-1")) + events.cancel() + } + + @Test func detachedStatusBeforeTurnRollsPastAClosedPriorGeneration() async throws { + let hub = ThreadEventHub() + let oldOutcome = CodexTurnOutcome.completed(.init(turnID: "turn-old")) + hub.beginGeneration(for: "thread-detached", including: "turn-old") + try hub.route(.terminal(oldOutcome), for: "thread-detached") + try hub.route(.closed, for: "thread-detached") + + try hub.route(.statusChanged(.active(activeFlags: [])), for: "thread-detached") + try hub.route(.turnStarted("turn-new"), for: "thread-detached") + hub.beginGeneration(for: "thread-detached", including: "turn-new") + + let events = hub.events(for: "thread-detached") + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-new", + state: .inProgress + ))) + #expect(try await iterator.next() == .statusChanged(.active(activeFlags: []))) + #expect(hub.snapshotForTesting(threadID: "thread-detached").currentTurnID == "turn-new") + events.cancel() + } + + @Test func aSlowSubscriberCompactsIndependentlyAndNewSnapshotSupersedesItsOldSuffix() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let fast = hub.events(for: "thread-1") + let slow = hub.events(for: "thread-1") + var fastIterator = fast.makeAsyncIterator() + var slowIterator = slow.makeAsyncIterator() + + #expect(try await fastIterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + for index in 0..<600 { + if index == 300 { + let snapshot = CodexTurnSnapshot( + id: "turn-1", + state: .inProgress, + startedAt: Date(timeIntervalSince1970: 300) + ) + try hub.route(.snapshot(snapshot), for: "thread-1") + #expect(try await fastIterator.next() == .snapshot(snapshot)) + } + let event = CodexThreadEvent.unknown(.init( + method: "probe/\(index)", + params: Data(), + threadID: "thread-1", + turnID: "turn-1" + )) + try hub.route(event, for: "thread-1") + #expect(try await fastIterator.next() == event) + } + + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + #expect(snapshot.overflowCount >= 2) + guard case .snapshot(let compactSnapshot) = try #require(try await slowIterator.next()) else { + Issue.record("Expected the slow subscriber's compact baseline first.") + fast.cancel() + slow.cancel() + return + } + #expect(compactSnapshot.startedAt == Date(timeIntervalSince1970: 300)) + #expect(snapshot.subscriberCount == 2) + fast.cancel() + slow.cancel() + } + + @Test func discardingAFailedCheckpointPreservesThePriorCurrentGeneration() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-current") + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + try hub.route(.turnStarted("turn-rejected"), for: "thread-1") + + hub.discard(checkpoint) + + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + #expect(snapshot.currentTurnID == "turn-current") + #expect(snapshot.pendingCheckpointCount == 0) + var iterator = hub.events(for: "thread-1").makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-current", + state: .inProgress, + itemsLoadState: .notLoaded + ))) + } + + @Test func threadProjectionsReleaseDedupeStateAcrossGenerations() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let messages = CodexThreadMessageSequence(events: hub.events(for: "thread-1")) + let logs = CodexThreadLogSequence(events: hub.events(for: "thread-1")) + var messageIterator = messages.makeAsyncIterator() + var logIterator = logs.makeAsyncIterator() + let first = CodexThreadItem( + id: "shared-id", + kind: .agentMessage, + content: .message(.init(id: "shared-id", role: .assistant, text: "First")) + ) + + try hub.route(.itemCompleted(first, turnID: "turn-1"), for: "thread-1") + #expect(try await messageIterator.next()?.text == "First") + #expect(try await logIterator.next()?.item?.text == "First") + try hub.route( + .terminal(.completed(.init(turnID: "turn-1"))), + for: "thread-1" + ) + + try hub.route(.turnStarted("turn-2"), for: "thread-1") + let second = CodexThreadItem( + id: "shared-id", + kind: .agentMessage, + content: .message(.init(id: "shared-id", role: .assistant, text: "Second")) + ) + try hub.route(.itemCompleted(second, turnID: "turn-2"), for: "thread-1") + + #expect(try await messageIterator.next()?.text == "Second") + #expect(try await logIterator.next()?.item?.text == "Second") + } + + @Test func discardedUniqueCheckpointsDoNotAccumulateEmptyThreadState() throws { + let hub = ThreadEventHub() + + for index in 0..<100 { + let checkpoint = try hub.registerCheckpoint(for: .init(rawValue: "thread-\(index)")) + hub.discard(checkpoint) + } + + let snapshot = hub.snapshotForTesting(threadID: "probe") + #expect(snapshot.pendingCheckpointCount == 0) + #expect(snapshot.threadStateCount == 0) + } + + @Test func aDifferentTurnAtomicallySupersedesTheTerminalGeneration() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + try hub.route( + .terminal(.completed(.init(turnID: "turn-1"))), + for: "thread-1" + ) + + try hub.route(.turnStarted("turn-2"), for: "thread-1") + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-2", + state: .inProgress + ))) + #expect(hub.snapshotForTesting(threadID: "thread-1").currentTurnID == "turn-2") + events.cancel() + } + + @Test func closedGenerationCanBeReplacedByAnExplicitRequestCheckpoint() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-old") + try hub.route(.closed, for: "thread-1") + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + try hub.route(.turnStarted("turn-new"), for: "thread-1") + hub.commit(checkpoint) + + var iterator = hub.events(for: "thread-1").makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-new", + state: .inProgress + ))) + #expect(hub.snapshotForTesting(threadID: "thread-1").isClosed == false) + } + + @Test func emptyCheckpointStillCreatesAnOpaqueGenerationBoundary() async throws { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-old") + let oldOutcome = CodexTurnOutcome.completed(.init(turnID: "turn-old")) + try hub.route(.terminal(oldOutcome), for: "thread-1") + let events = hub.events(for: "thread-1") + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-old", + state: .completed, + itemsLoadState: .notLoaded + ))) + #expect(try await iterator.next() == .terminal(oldOutcome)) + + let checkpoint = try hub.registerCheckpoint(for: "thread-1") + hub.activate(checkpoint) + hub.commit(checkpoint) + try hub.route(.statusChanged(.active(activeFlags: [])), for: "thread-1") + try hub.route(.turnStarted("turn-new"), for: "thread-1") + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-new", + state: .inProgress + ))) + #expect(try await iterator.next() == .statusChanged(.active(activeFlags: []))) + events.cancel() + } + + @Test func connectionFailureClearsGenerationsAndFailsCurrentAndLateSubscribersIdentically() async { + let hub = ThreadEventHub() + hub.beginGeneration(for: "thread-1", including: "turn-1") + let events = hub.events(for: "thread-1") + var current = events.makeAsyncIterator() + let error = CodexAppServerError.connectionTerminated(.processExited(status: 9)) + + hub.finish(throwing: error) + + await expectFailure(error, from: ¤t) + var late = hub.events(for: "thread-1").makeAsyncIterator() + await expectFailure(error, from: &late) + let snapshot = hub.snapshotForTesting(threadID: "thread-1") + #expect(snapshot.hasCurrentGeneration == false) + #expect(snapshot.subscriberCount == 0) + #expect(snapshot.failure == error) + } + + @Test func checkpointTransitionsAreHarmlessAfterConnectionTermination() throws { + let error = CodexAppServerError.connectionTerminated(.processExited(status: 9)) + + let activateHub = ThreadEventHub() + let inactive = try activateHub.registerCheckpoint(for: "thread-activate") + activateHub.finish(throwing: error) + activateHub.activate(inactive) + + let rejectHub = ThreadEventHub() + let rejected = try rejectHub.registerCheckpoint(for: "thread-reject") + rejectHub.activate(rejected) + rejectHub.finish(throwing: error) + rejectHub.reject(rejected) + + let commitHub = ThreadEventHub() + let committed = try commitHub.registerCheckpoint(for: "thread-commit") + commitHub.activate(committed) + commitHub.finish(throwing: error) + commitHub.commit(committed) + + do { + _ = try commitHub.registerCheckpoint(for: "thread-late") + Issue.record("Expected registration after failure to throw.") + } catch let thrown as CodexAppServerError { + #expect(thrown == error) + } + commitHub.resetGeneration(for: "thread-commit") + commitHub.beginGeneration(for: "thread-commit", including: "turn-late") + do { + try commitHub.route(.statusChanged(.idle), for: "thread-commit") + Issue.record("Expected routing after failure to throw.") + } catch let thrown as CodexAppServerError { + #expect(thrown == error) + } + } + + @Test func cancellationAfterHubRemovalDiscardsClosedAndFailedDelivery() async throws { + let closedHub = ThreadEventHub() + closedHub.beginGeneration(for: "thread-closed", including: "turn-1") + let closed = closedHub.events(for: "thread-closed") + try closedHub.route(.closed, for: "thread-closed") + #expect(closedHub.snapshotForTesting(threadID: "thread-closed").subscriberCount == 0) + closed.cancel() + var closedIterator = closed.makeAsyncIterator() + #expect(try await closedIterator.next() == nil) + + let failedHub = ThreadEventHub() + let failed = failedHub.events(for: "thread-failed") + failedHub.finish(throwing: .connectionTerminated(.processExited(status: 9))) + failed.cancel() + var failedIterator = failed.makeAsyncIterator() + #expect(try await failedIterator.next() == nil) + } + + @Test func explicitTaskAndLastCopyCancellationRemoveOnlyTheirSubscriber() async throws { + let hub = ThreadEventHub() + let retained = hub.events(for: "thread-1") + var explicit: CodexThreadEventSequence? = hub.events(for: "thread-1") + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 2) + + explicit?.cancel() + explicit = nil + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 1) + + do { + _ = hub.events(for: "thread-1") + } + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 1) + + let waiting = hub.events(for: "thread-1") + let task = Task { + var iterator = waiting.makeAsyncIterator() + return try await iterator.next() + } + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 2) + task.cancel() + #expect(try await task.value == nil) + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 1) + + retained.cancel() + #expect(hub.snapshotForTesting(threadID: "thread-1").subscriberCount == 0) + } +} + +private final class PublicationGate: @unchecked Sendable { + private let blocked = DispatchSemaphore(value: 0) + private let released = DispatchSemaphore(value: 0) + + func blockPublication() { + blocked.signal() + released.wait() + } + + func waitUntilBlocked() -> Bool { + blocked.wait(timeout: .now() + 5) == .success + } + + func releasePublication() { + released.signal() + } +} + +private func collect( + from sequence: CodexThreadEventSequence +) async throws -> [CodexThreadEvent] { + var iterator = sequence.makeAsyncIterator() + var events: [CodexThreadEvent] = [] + while let event = try await iterator.next() { + events.append(event) + } + return events +} + +private func messageItem(id: String, text: String) -> CodexThreadItem { + .init( + id: id, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, text: text)) + ) +} + +private func expectFailure( + _ expected: CodexAppServerError, + from iterator: inout CodexThreadEventSequence.Iterator +) async { + do { + _ = try await iterator.next() + Issue.record("Expected thread events to fail.") + } catch let error as CodexAppServerError { + #expect(error == expected) + } catch { + Issue.record("Unexpected thread event failure: \(error)") + } +} diff --git a/Tests/CodexAppServerKitTests/TurnReplayCoreTests.swift b/Tests/CodexAppServerKitTests/TurnReplayCoreTests.swift new file mode 100644 index 00000000..2f6d8b33 --- /dev/null +++ b/Tests/CodexAppServerKitTests/TurnReplayCoreTests.swift @@ -0,0 +1,356 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit + +@Suite("Turn replay core") +struct TurnReplayCoreTests { + @Test func pendingTokensAreUniqueAndOperationKindsPreserveRoutingIdentity() { + let first = TurnReplayPendingToken() + let second = TurnReplayPendingToken() + + #expect(first != second) + #expect( + TurnReplayPendingOperationKind.turn(threadID: "thread-1") == + .turn(threadID: "thread-1") + ) + #expect( + TurnReplayPendingOperationKind.review( + sourceThreadID: "thread-1", + delivery: .detached + ) == .review(sourceThreadID: "thread-1", delivery: .detached) + ) + } + + @Test func compactSnapshotReplaysFinalSnapshotThenTerminal() { + let compact = makeCompactSnapshot() + + #expect(compact.replayEvents == [ + .snapshot(compact.snapshot), + .terminal(compact.outcome), + ]) + } + + @Test func twoHundredFiftySeventhIncrementalAtomicallyCompactsToCurrentSnapshot() async throws { + let relay = TurnReplayRelay() + let events = relay.events() + var iterator = events.makeAsyncIterator() + let snapshot = CodexTurnSnapshot( + id: "turn-1", + state: .inProgress, + items: [makeItem(id: "item-current")] + ) + + for index in 0..<256 { + #expect(relay.yield( + .unknown(.init(method: "event/\(index)", params: Data())), + accumulatedSnapshot: snapshot + ) == 0) + } + #expect(relay.yield( + .unknown(.init(method: "event/256", params: Data())), + accumulatedSnapshot: snapshot + ) == 1) + relay.yield(.started("turn-1"), accumulatedSnapshot: snapshot) + + #expect(try await iterator.next() == .snapshot(snapshot)) + #expect(try await iterator.next() == .started("turn-1")) + events.cancel() + } + + @Test func secondOverflowSupersedesEarlierSnapshotAndSuffix() async throws { + let relay = TurnReplayRelay() + let events = relay.events() + var iterator = events.makeAsyncIterator() + let firstSnapshot = CodexTurnSnapshot(id: "turn-1", state: .inProgress) + let secondSnapshot = CodexTurnSnapshot( + id: "turn-1", + state: .inProgress, + items: [makeItem(id: "newest")] + ) + + for index in 0..<257 { + _ = relay.yield( + .unknown(.init(method: "first/\(index)", params: Data())), + accumulatedSnapshot: firstSnapshot + ) + } + for index in 0..<257 { + _ = relay.yield( + .unknown(.init(method: "second/\(index)", params: Data())), + accumulatedSnapshot: secondSnapshot + ) + } + + #expect(try await iterator.next() == .snapshot(secondSnapshot)) + events.cancel() + } + + @Test func terminalUsesReservedSlotAndSupersedesPendingIncrementals() async throws { + let relay = TurnReplayRelay() + let events = relay.events() + var iterator = events.makeAsyncIterator() + let compact = makeCompactSnapshot() + + for index in 0..<32 { + relay.yield( + .unknown(.init(method: "event/\(index)", params: Data())), + accumulatedSnapshot: compact.snapshot + ) + } + relay.finish(with: compact) + + #expect(try await iterator.next() == .snapshot(compact.snapshot)) + #expect(try await iterator.next() == .terminal(compact.outcome)) + #expect(try await iterator.next() == nil) + } + + @Test func lateSequencesReplayOnlyCompactTerminalState() async throws { + let compact = makeCompactSnapshot() + let events = TurnReplayEvents.replaying(compact) + let progress = TurnReplayProgressEvents.replaying(compact) + var eventIterator = events.makeAsyncIterator() + var progressIterator = progress.makeAsyncIterator() + + #expect(try await eventIterator.next() == .snapshot(compact.snapshot)) + #expect(try await eventIterator.next() == .terminal(compact.outcome)) + #expect(try await eventIterator.next() == nil) + #expect(try await progressIterator.next() == .terminal(compact.outcome)) + #expect(try await progressIterator.next() == nil) + } + + @Test func fastSubscriberDoesNotWaitForSlowSubscriberCompaction() async throws { + let relay = TurnReplayRelay() + let fastEvents = relay.events() + let slowEvents = relay.events() + var fastIterator = fastEvents.makeAsyncIterator() + var slowIterator = slowEvents.makeAsyncIterator() + let snapshot = CodexTurnSnapshot(id: "turn-1", state: .inProgress) + + for index in 0..<257 { + let event = CodexTurnEvent.unknown(.init( + method: "event/\(index)", + params: Data() + )) + _ = relay.yield(event, accumulatedSnapshot: snapshot) + #expect(try await fastIterator.next() == event) + } + + #expect(try await slowIterator.next() == .snapshot(snapshot)) + fastEvents.cancel() + slowEvents.cancel() + } + + @Test func eventSeedAndPublicationAreAtomicWithConcurrentYield() async throws { + let relay = TurnReplayRelay() + let publicationEntered = DispatchSemaphore(value: 0) + let releasePublication = DispatchSemaphore(value: 0) + let producerStarted = DispatchSemaphore(value: 0) + let snapshot = CodexTurnSnapshot(id: "turn-1", state: .inProgress) + let event = CodexTurnEvent.started("turn-1") + let subscription = Task { + await performOnGlobalQueue { + relay.eventsForTesting(initialSnapshot: snapshot) { + publicationEntered.signal() + releasePublication.wait() + } + } + } + await waitForSemaphore(publicationEntered) + let producer = Task { + await performOnGlobalQueue { + producerStarted.signal() + return relay.yield(event, accumulatedSnapshot: snapshot) + } + } + await waitForSemaphore(producerStarted) + releasePublication.signal() + + let events = await subscription.value + _ = await producer.value + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == .snapshot(snapshot)) + #expect(try await iterator.next() == event) + events.cancel() + } + + @Test func snapshotDoesNotWaitForBlockedInitialPublication() async throws { + let relay = TurnReplayRelay() + let publicationEntered = DispatchSemaphore(value: 0) + let releasePublication = DispatchSemaphore(value: 0) + let snapshotCompleted = DispatchSemaphore(value: 0) + let snapshot = CodexTurnSnapshot(id: "turn-1", state: .inProgress) + let subscription = Task { + await performOnGlobalQueue { + relay.eventsForTesting(initialSnapshot: snapshot) { + publicationEntered.signal() + releasePublication.wait() + } + } + } + defer { releasePublication.signal() } + let publicationDidEnter = await performOnGlobalQueue { + publicationEntered.wait(timeout: .now() + 5) == .success + } + try #require(publicationDidEnter) + + let snapshotTask = Task.detached { + let captured = relay.snapshotForTesting() + snapshotCompleted.signal() + return captured + } + let completedWhilePublicationWasBlocked = await performOnGlobalQueue { + snapshotCompleted.wait(timeout: .now() + 5) == .success + } + releasePublication.signal() + + let events = await subscription.value + let captured = await snapshotTask.value + #expect(completedWhilePublicationWasBlocked) + #expect(captured == .init(subscriberCount: 0, overflowCount: 0, isFinished: false)) + events.cancel() + } + + @Test func progressKeepsNewestRunningValueAndReservedTerminal() async throws { + let relay = TurnReplayRelay() + let events = relay.progressEvents() + var iterator = events.makeAsyncIterator() + let older = CodexReviewProgress.running( + transcript: .init(items: [makeItem(id: "older")]), + usage: nil + ) + let newest = CodexReviewProgress.running( + transcript: .init(items: [makeItem(id: "newest")]), + usage: nil + ) + let compact = makeCompactSnapshot() + + relay.yieldProgress(older) + relay.yieldProgress(newest) + relay.finish(with: compact) + + #expect(try await iterator.next() == newest) + #expect(try await iterator.next() == .terminal(compact.outcome)) + #expect(try await iterator.next() == nil) + } + + @Test func progressSeedAndPublicationAreAtomicWithConcurrentYield() async throws { + let relay = TurnReplayRelay() + let publicationEntered = DispatchSemaphore(value: 0) + let releasePublication = DispatchSemaphore(value: 0) + let producerStarted = DispatchSemaphore(value: 0) + let initial = CodexReviewProgress.running(transcript: .init(), usage: nil) + let newest = CodexReviewProgress.running( + transcript: .init(items: [makeItem(id: "newest")]), + usage: nil + ) + let subscription = Task { + await performOnGlobalQueue { + relay.progressEventsForTesting(initialProgress: initial) { + publicationEntered.signal() + releasePublication.wait() + } + } + } + await waitForSemaphore(publicationEntered) + let producer = Task { + await performOnGlobalQueue { + producerStarted.signal() + relay.yieldProgress(newest) + } + } + await waitForSemaphore(producerStarted) + releasePublication.signal() + + let events = await subscription.value + _ = await producer.value + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next() == newest) + events.cancel() + } + + @Test func explicitCancellationSynchronouslyRemovesOnlyThatSubscriber() async throws { + let relay = TurnReplayRelay() + let cancelled = relay.events() + let retained = relay.events() + var cancelledIterator = cancelled.makeAsyncIterator() + + #expect(relay.snapshotForTesting().subscriberCount == 2) + cancelled.cancel() + #expect(relay.snapshotForTesting().subscriberCount == 1) + #expect(try await cancelledIterator.next() == nil) + #expect(relay.snapshotForTesting().subscriberCount == 1) + retained.cancel() + #expect(relay.snapshotForTesting().subscriberCount == 0) + } + + @Test func connectionFailureTerminatesEventAndProgressSubscribersWithTypedError() async { + let relay = TurnReplayRelay() + let events = relay.events() + let progress = relay.progressEvents() + var eventIterator = events.makeAsyncIterator() + var progressIterator = progress.makeAsyncIterator() + let failure = CodexAppServerError.connectionTerminated( + .transportFailure(.io(errno: 5, message: "read failed")) + ) + + relay.finish(throwing: failure) + + do { + _ = try await eventIterator.next() + Issue.record("Expected the event subscriber to fail.") + } catch let error as CodexAppServerError { + #expect(error == failure) + } catch { + Issue.record("Unexpected event error: \(error)") + } + + do { + _ = try await progressIterator.next() + Issue.record("Expected the progress subscriber to fail.") + } catch let error as CodexAppServerError { + #expect(error == failure) + } catch { + Issue.record("Unexpected progress error: \(error)") + } + } + + private func makeCompactSnapshot() -> CompactTurnSnapshot { + let response = CodexResponse( + turnID: "turn-1", + transcript: .init(items: [makeItem(id: "terminal")]) + ) + return .init( + snapshot: .init( + id: "turn-1", + state: .completed, + items: response.transcript.items + ), + outcome: .completed(response) + ) + } + + private func makeItem(id: String) -> CodexThreadItem { + .init( + id: id, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, text: id)) + ) + } +} + +private func waitForSemaphore(_ semaphore: DispatchSemaphore) async { + await performOnGlobalQueue { + semaphore.wait() + } +} + +private func performOnGlobalQueue( + _ operation: @escaping @Sendable () -> Result +) async -> Result { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: operation()) + } + } +} diff --git a/Tests/CodexAppServerKitTests/TurnReplayRouterTests.swift b/Tests/CodexAppServerKitTests/TurnReplayRouterTests.swift new file mode 100644 index 00000000..1081cf6f --- /dev/null +++ b/Tests/CodexAppServerKitTests/TurnReplayRouterTests.swift @@ -0,0 +1,213 @@ +import Testing + +@testable import CodexAppServerKit +import CodexAppServerKitTesting + +@Suite("Turn replay router integration") +struct TurnReplayRouterTests { + @Test func duplicateExplicitThreadTerminalsPublishOnce() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + let completed = TurnCompletedParams( + threadID: "thread-1", + turn: .init(id: "turn-external", status: "completed") + ) + let events = harness.router.events(for: "thread-1") + var eventIterator = events.makeAsyncIterator() + + try await transport.emitServerNotification(method: "turn/completed", params: completed) + try await transport.emitServerNotification(method: "turn/completed", params: completed) + try await transport.emitServerNotification( + method: "thread/closed", + params: ThreadClosedParams(threadID: "thread-1") + ) + + var terminals: [CodexTurnOutcome] = [] + while let event = try await eventIterator.next() { + if case .terminal(let outcome) = event { + terminals.append(outcome) + } + } + await harness.close() + #expect(terminals == [.completed(.init(turnID: "turn-external"))]) + } + + @Test func conflictingExplicitThreadTerminalClosesConnectionWithContractViolation() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-1", + turn: .init(id: "turn-external", status: "completed") + ) + ) + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-1", + turn: .init(id: "turn-external", status: "interrupted") + ) + ) + + let termination = await harness.supervisor.waitForTerminationForTesting() + if case .transportFailure(.contractViolation(let message)) = termination { + #expect(message.contains("turn-external")) + } else { + Issue.record("Expected a typed thread-terminal contract violation, got \(termination).") + } + await harness.close() + } + + @Test func resumedReviewCapturesTerminalBeforeResumeResponse() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-review")) + await runtime.transport.holdNext(method: "thread/resume", gate: gate) + let identity = CodexReviewIdentity( + threadID: "thread-source", + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + let resume = Task { + try await runtime.server.resumeReview(identity) + } + + await runtime.transport.waitForRequest(method: "thread/resume") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-review", + turn: .init(id: "turn-review", status: "completed") + ) + ) + await gate.open() + + let review = try await resume.value + let outcome = try await review.collect(timeout: .seconds(1)) + #expect(outcome == .completed(.init(turnID: "turn-review"))) + await runtime.close() + } + + @Test func terminalCommitsRouterStateBeforeReplayPublication() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + let publicationGate = CodexAppServerTestGate() + await harness.router.setTerminalReplayPublicationPauseForTesting { + await publicationGate.waitIgnoringCancellation() + } + await harness.router.seedTurns( + [ + .init( + id: "turn-terminal-order", + state: .inProgress, + items: [ + .init( + id: "message-terminal-order", + kind: .agentMessage, + content: .message(.init( + id: "message-terminal-order", + role: .assistant, + text: "Pending" + )) + ), + ] + ), + ], + threadID: "thread-terminal-order" + ) + + try await transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-terminal-order", + turn: .init(id: "turn-terminal-order", status: "completed") + ) + ) + await publicationGate.waitUntilBlocked() + + #expect(await harness.router.turnAssociationForTesting("turn-terminal-order") == nil) + #expect(await harness.router.itemSnapshotForTesting( + turnID: "turn-terminal-order", + itemID: "message-terminal-order" + ) == nil) + + await publicationGate.open() + await harness.close() + } + + @Test func conflictingNotificationTurnAssociationTerminatesConnection() async throws { + let transport = CodexAppServerTestTransport() + let harness = await CodexAppServerTestConnectionHarness.start(transport: transport) + await transport.waitForNotificationStreamCount(1) + await harness.router.seedTurn("turn-associated", threadID: "thread-owner") + + try await transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-other", + turnID: "turn-associated" + ) + ) + + let termination = await harness.supervisor.waitForTerminationForTesting() + if case .transportFailure(.contractViolation(let message)) = termination { + #expect(message.contains("turn-associated")) + #expect(message.contains("thread-owner")) + #expect(message.contains("thread-other")) + } else { + Issue.record("Expected a typed turn-association contract violation, got \(termination).") + } + await harness.close() + } +} + +private struct TurnCompletedParams: Encodable, Sendable { + var threadID: String + var turn: TurnPayload + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } +} + +private struct TurnPayload: Encodable, Sendable { + var id: String + var status: String + var items: [TurnItem] = [] +} + +private struct TurnStartedParams: Encodable, Sendable { + var threadID: String + var turn: Turn + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + init(threadID: String, turnID: String) { + self.threadID = threadID + self.turn = .init(id: turnID) + } + + struct Turn: Encodable, Sendable { + var id: String + var status = "inProgress" + var items: [String] = [] + } +} + +private struct ThreadClosedParams: Encodable, Sendable { + var threadID: String + + private enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +private struct TurnItem: Encodable, Sendable {} diff --git a/Tests/CodexAppServerKitTests/TurnReplayStoreTests.swift b/Tests/CodexAppServerKitTests/TurnReplayStoreTests.swift new file mode 100644 index 00000000..af84555e --- /dev/null +++ b/Tests/CodexAppServerKitTests/TurnReplayStoreTests.swift @@ -0,0 +1,925 @@ +import Foundation +import Testing + +@testable import CodexAppServerKit +import CodexAppServerKitTesting + +@Suite("Turn replay store") +struct TurnReplayStoreTests { + @Test func terminalTransitionReleasesConcreteConnectionLeaseAndCachesOutcome() async throws { + var lease: AppServerConnectionLease? + let terminationToken: ProcessTerminationToken + (lease, terminationToken) = makeConnectionLease() + let weakLease = WeakReference(lease) + let state = TurnGenerationHandleState(connectionLease: try #require(lease)) + lease = nil + let compact = makeCompactSnapshot() + + #expect(weakLease.value != nil) + #expect(await state.snapshot() == .live) + #expect(await state.transitionToTerminal(compact) == .transitioned) + #expect(weakLease.value == nil) + #expect(terminationToken.didRequestTermination) + #expect(await state.snapshot() == .terminal(compact)) + #expect(try await state.cachedOutcome() == compact.outcome) + #expect(await state.transitionToTerminal(compact) == .duplicate) + await state.closeConnection() + } + + @Test func connectionTerminationReleasesLeaseAndIsReplayedAsTypedFailure() async throws { + var lease: AppServerConnectionLease? + let terminationToken: ProcessTerminationToken + (lease, terminationToken) = makeConnectionLease() + let weakLease = WeakReference(lease) + let state = TurnGenerationHandleState(connectionLease: try #require(lease)) + lease = nil + let termination = CodexConnectionTermination.processExited(status: 9) + + #expect(await state.transitionToTerminated(termination) == .transitioned) + #expect(weakLease.value == nil) + #expect(terminationToken.didRequestTermination) + #expect(await state.snapshot() == .terminated(termination)) + do { + _ = try await state.cachedOutcome() + Issue.record("Expected cached outcome lookup to fail after connection termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(termination)) + } catch { + Issue.record("Unexpected cached outcome error: \(error)") + } + + var events = try #require(try await state.terminalEvents()).makeAsyncIterator() + do { + _ = try await events.next() + Issue.record("Expected late events to fail with connection termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(termination)) + } catch { + Issue.record("Unexpected late event error: \(error)") + } + } + + @Test func preWriteCancellationRemovesRegistrationWhilePostWriteRetainsIt() async throws { + let store = TurnReplayStore() + let state = makeState() + let beforeWrite = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + + #expect(await store.cancelPendingOperation(beforeWrite) == .removedBeforeWrite) + #expect(await store.snapshotForTesting().pendingOperationCount == 0) + + let afterWrite = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + afterWrite.acceptWrite() + #expect(await store.cancelPendingOperation(afterWrite) == .retainedAfterWrite) + #expect(await store.snapshotForTesting().postWritePendingOperationCount == 1) + + await store.bind( + afterWrite, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let snapshot = await store.snapshotForTesting() + #expect(snapshot.pendingOperationCount == 0) + #expect(snapshot.activeGenerationCount == 1) + #expect(snapshot.weakStateRegistrationCount == 1) + } + + @Test func rejectedWriteAttemptReturnsPendingOperationToPreWriteCancellation() async { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + + pending.acceptWrite() + pending.rejectAcceptedWrite() + + #expect(await store.cancelPendingOperation(pending) == .removedBeforeWrite) + #expect(await store.snapshotForTesting().pendingOperationCount == 0) + } + + @Test func restoredHandleCopiesResolveOneCanonicalGenerationState() async { + let store = TurnReplayStore() + let (lease, _) = makeConnectionLease() + let initialSnapshot = CodexTurnSnapshot(id: "turn-1", state: .inProgress) + + let first = await store.restoreGeneration( + turnID: "turn-1", + initialSnapshot: initialSnapshot, + connectionLease: lease + ) + let second = await store.restoreGeneration( + turnID: "turn-1", + initialSnapshot: initialSnapshot, + connectionLease: lease + ) + + #expect(first === second) + #expect(await store.snapshotForTesting().weakStateRegistrationCount == 1) + } + + @Test func soleFailedRestoreReservationRemovesItsProvisionalGeneration() async { + let store = TurnReplayStore() + let (lease, _) = makeConnectionLease() + let reservation = await store.reserveRestoredGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + + #expect(await store.discardRestoredGeneration(reservation)) + #expect(await store.snapshotForTesting().activeGenerationCount == 0) + } + + @Test func failedRestoreReservationPreservesAnExistingCanonicalHandle() async { + let store = TurnReplayStore() + let (lease, _) = makeConnectionLease() + let existing = await store.restoreGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + let reservation = await store.reserveRestoredGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + + #expect(reservation.state === existing) + #expect(await store.discardRestoredGeneration(reservation) == false) + #expect(await store.snapshotForTesting().activeGenerationCount == 1) + await store.finish(.completed(.init(turnID: "turn-1"))) + } + + @Test func concurrentRestoreFailureCannotDiscardCommittedReservation() async { + let store = TurnReplayStore() + let (lease, _) = makeConnectionLease() + let failed = await store.reserveRestoredGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + let committed = await store.reserveRestoredGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + + #expect(failed.state === committed.state) + #expect(await store.discardRestoredGeneration(failed) == false) + await store.commitRestoredGeneration(committed) + #expect(await store.snapshotForTesting().activeGenerationCount == 1) + await store.finish(.completed(.init(turnID: "turn-1"))) + } + + @Test func earlyTerminalMakesRestoreCommitAndDiscardFinalizedNoOps() async throws { + for shouldCommit in [false, true] { + let store = TurnReplayStore() + let (lease, _) = makeConnectionLease() + let reservation = await store.reserveRestoredGeneration( + turnID: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress), + connectionLease: lease + ) + + await store.finish(.completed(.init(turnID: "turn-1"))) + if shouldCommit { + await store.commitRestoredGeneration(reservation) + } else { + #expect(await store.discardRestoredGeneration(reservation) == false) + } + + #expect(try await reservation.state.cachedOutcome()?.response.turnID == "turn-1") + #expect(await store.snapshotForTesting().activeGenerationCount == 0) + } + } + + @Test func untrackedThreadEventsDoNotCreatePerTurnReplayState() async { + let store = TurnReplayStore() + + #expect( + await store.routeIfTracked(.started("turn-external"), for: "turn-external") + == .untracked + ) + #expect( + await store.finishIfTracked(.completed(.init(turnID: "turn-external"))) == .untracked + ) + let snapshot = await store.snapshotForTesting() + #expect(snapshot.pendingOperationCount == 0) + #expect(snapshot.activeGenerationCount == 0) + #expect(snapshot.orphanGenerationCount == 0) + } + + @Test func pendingReviewDoesNotBindLeakedChildTurnStart() async { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .review(sourceThreadID: "thread-1", delivery: .inline), + state: state + ) + pending.acceptWrite() + + #expect( + await store.routeIfTracked(.started("turn-child"), for: "turn-child") == .untracked + ) + #expect(await store.snapshotForTesting().orphanGenerationCount == 0) + + await store.bind( + pending, + to: "turn-review", + initialSnapshot: .init(id: "turn-review", state: .inProgress) + ) + #expect(await store.snapshotForTesting().activeGenerationCount == 1) + } + + @Test func terminalHandoffTransitionsStateBeforeDeletingRawGeneration() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .review(sourceThreadID: "thread-1", delivery: .inline), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let events = try await store.events(for: "turn-1", state: state) + var iterator = events.makeAsyncIterator() + let item = makeItem(id: "live-item") + + #expect(try await iterator.next() == .snapshot(.init( + id: "turn-1", + state: .inProgress + ))) + _ = await store.yield(.itemCompleted(item), for: "turn-1") + await store.finish(.completed(.init(turnID: "turn-1"))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.items == [item]) + #expect(terminalState.snapshot.itemsLoadState == .full) + #expect(terminalState.outcome.response.transcriptItemsLoadState == .full) + #expect(await store.snapshotForTesting().activeGenerationCount == 0) + #expect(try await iterator.next() == .snapshot(terminalState.snapshot)) + #expect(try await iterator.next() == .terminal(terminalState.outcome)) + #expect(try await iterator.next() == nil) + + var lateIterator = try await store.events( + for: "turn-1", + state: state + ).makeAsyncIterator() + #expect(try await lateIterator.next() == .snapshot(terminalState.snapshot)) + #expect(try await lateIterator.next() == .terminal(terminalState.outcome)) + #expect(try await lateIterator.next() == nil) + } + + @Test func terminalReplayPreservesSummaryTranscriptCompleteness() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let summaryItem = makeItem(id: "summary-message") + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [summaryItem] + ) + ) + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [summaryItem]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .summary) + #expect(terminalState.outcome.response.transcriptItemsLoadState == .summary) + } + + @Test func startedReplayBuildsAFullTerminalTranscript() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let liveItem = makeItem(id: "live-message") + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ) + ) + _ = await store.yield(.started("turn-1"), for: "turn-1") + _ = await store.yield(.itemCompleted(liveItem), for: "turn-1") + let terminalItem = makeItem(id: "terminal-message") + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalItem]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .full) + #expect(terminalState.snapshot.items == [liveItem, terminalItem]) + #expect(terminalState.outcome.response.transcriptItemsLoadState == .full) + #expect(terminalState.outcome.response.transcript.items == [liveItem, terminalItem]) + } + + @Test func summaryTerminalItemDoesNotReplaceAFullReplayItem() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ) + ) + let fullItem = makeItem(id: "message", text: "Complete response") + _ = await store.yield(.started("turn-1"), for: "turn-1") + _ = await store.yield(.itemCompleted(fullItem), for: "turn-1") + let summaryItem = makeItem(id: "message", text: "Summary response") + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [summaryItem]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .full) + #expect(terminalState.snapshot.items == [fullItem]) + #expect(terminalState.outcome.response.transcript.items == [fullItem]) + } + + @Test func sparseTerminalPreservesItemsObservedAfterAPartialSeed() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let seededItem = makeItem(id: "seeded", text: "Seeded summary") + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [seededItem] + ) + ) + let observedItem = makeItem(id: "observed", text: "Complete live response") + _ = await store.yield(.itemCompleted(observedItem), for: "turn-1") + let terminalItem = makeItem(id: "seeded", text: "Terminal summary") + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalItem]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .summary) + #expect(terminalState.snapshot.items == [terminalItem, observedItem]) + #expect(terminalState.outcome.response.transcript.items == [ + terminalItem, + observedItem, + ]) + } + + @Test func sparseTerminalUpdatesAnUnobservedKindSharingAnObservedRawID() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let entered = CodexThreadItem( + id: "review-marker", + kind: .enteredReviewMode, + content: .log("Entered") + ) + let staleExit = CodexThreadItem( + id: "review-marker", + kind: .exitedReviewMode, + content: .log("Stale") + ) + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [entered, staleExit] + ) + ) + let observedEntered = CodexThreadItem( + id: "review-marker", + kind: .enteredReviewMode, + content: .log("Entered live") + ) + _ = await store.yield(.itemCompleted(observedEntered), for: "turn-1") + let terminalExit = CodexThreadItem( + id: "review-marker", + kind: .exitedReviewMode, + content: .log("Final review") + ) + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [terminalExit]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.items == [observedEntered, terminalExit]) + #expect(terminalState.outcome.response.transcript.items == [ + observedEntered, + terminalExit, + ]) + } + + @Test func fullTerminalTranscriptRemovesOmittedReplayItems() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + let omittedItem = makeItem(id: "omitted") + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .full, + items: [omittedItem] + ) + ) + let retainedItem = makeItem(id: "retained") + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [retainedItem]), + transcriptItemsLoadState: .full + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .full) + #expect(terminalState.snapshot.items == [retainedItem]) + #expect(terminalState.outcome.response.transcript.items == [retainedItem]) + } + + @Test func responseCollectionDoesNotRestoreItemsOmittedByAFullTerminal() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .notLoaded + ) + ) + let events = CodexTurnEventSequence( + turnID: "turn-1", + store: store, + state: state + ) + let collection = Task { + try await CodexResponseCollector.collect(from: events) + } + _ = await store.yield( + .itemCompleted(makeItem(id: "omitted")), + for: "turn-1" + ) + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(), + transcriptItemsLoadState: .full + ))) + + let outcome = try await collection.value + #expect(outcome.response.transcript.items.isEmpty) + #expect(outcome.response.transcriptItemsLoadState == .full) + } + + @Test func diagnosticBeforeBindingDoesNotUpgradeTranscriptCompleteness() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + _ = await store.routeIfTracked( + .unknown(.init(method: "early", params: Data(), turnID: "turn-1")), + for: "turn-1" + ) + let summaryItem = makeItem(id: "summary-message") + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init( + id: "turn-1", + state: .inProgress, + itemsLoadState: .summary, + items: [summaryItem] + ) + ) + + await store.finish(.completed(.init( + turnID: "turn-1", + transcript: .init(items: [summaryItem]), + transcriptItemsLoadState: .summary + ))) + + let terminalState = try #require(await state.snapshot().terminalSnapshot) + #expect(terminalState.snapshot.itemsLoadState == .summary) + #expect(terminalState.outcome.response.transcriptItemsLoadState == .summary) + } + + @Test func lateTranscriptSubscriptionReplaysTerminalSnapshot() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let item = makeItem(id: "terminal-message") + _ = await store.yield(.itemCompleted(item), for: "turn-1") + await store.finish(.completed(.init(turnID: "turn-1"))) + + let events = CodexTurnEventSequence( + turnID: "turn-1", + store: store, + state: state + ) + var transcripts = CodexTurnTranscriptSequence(events: events).makeAsyncIterator() + + #expect(try await transcripts.next() == .init(items: [item])) + #expect(try await transcripts.next() == nil) + } + + @Test func earlyDetachedTerminalIsBoundedUntilResponseBindsItsState() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .review(sourceThreadID: "source-thread", delivery: .detached), + state: state + ) + pending.acceptWrite() + let item = makeItem(id: "early-item") + + _ = await store.yield(.itemCompleted(item), for: "early-turn") + await store.finish(.completed(.init(turnID: "early-turn"))) + var snapshot = await store.snapshotForTesting() + #expect(snapshot.pendingOperationCount == 1) + #expect(snapshot.orphanGenerationCount == 1) + #expect(snapshot.terminalOrphanCount == 1) + + await store.bind( + pending, + to: "early-turn", + initialSnapshot: .init(id: "early-turn", state: .inProgress) + ) + snapshot = await store.snapshotForTesting() + #expect(snapshot.pendingOperationCount == 0) + #expect(snapshot.orphanGenerationCount == 0) + #expect(snapshot.activeGenerationCount == 0) + let compact = try #require(await state.snapshot().terminalSnapshot) + #expect(compact.snapshot.items == [item]) + } + + @Test func orphanCapacityEqualsUnboundPostWriteOperationCount() async { + let store = TurnReplayStore() + let firstState = makeState() + let secondState = makeState() + let first = await store.registerPendingOperation( + kind: .review(sourceThreadID: "source-1", delivery: .detached), + state: firstState + ) + let second = await store.registerPendingOperation( + kind: .review(sourceThreadID: "source-2", delivery: .detached), + state: secondState + ) + first.acceptWrite() + second.acceptWrite() + + _ = await store.yield(.itemCompleted(makeItem(id: "early-item-1")), for: "early-1") + _ = await store.yield(.itemCompleted(makeItem(id: "early-item-2")), for: "early-2") + let snapshot = await store.snapshotForTesting() + #expect(snapshot.postWritePendingOperationCount == 2) + #expect(snapshot.orphanGenerationCount == 2) + + await store.terminateAll(with: .closedByCaller) + } + + @Test func orphanGenerationNPlusOneFailsFast() async { + await #expect(processExitsWith: .failure) { + let store = TurnReplayStore() + let state = makeTurnReplayExitTestState() + let pending = await store.registerPendingOperation( + kind: .review(sourceThreadID: "source", delivery: .detached), + state: state + ) + pending.acceptWrite() + _ = await store.yield( + .unknown(.init(method: "early-1", params: Data(), turnID: "early-1")), + for: "early-1" + ) + _ = await store.yield( + .unknown(.init(method: "early-2", params: Data(), turnID: "early-2")), + for: "early-2" + ) + } + } + + @Test func storeKeepsOnlyWeakHandleStateRegistrations() async throws { + let store = TurnReplayStore() + var state: TurnGenerationHandleState? = makeState() + let weakState = WeakReference(state) + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: try #require(state) + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + + state = nil + #expect(weakState.value == nil) + #expect(await store.snapshotForTesting().weakStateRegistrationCount == 0) + await store.finish(.completed(.init(turnID: "turn-1"))) + #expect(await store.snapshotForTesting().activeGenerationCount == 0) + } + + @Test func distinctStateRegistrationFailsFast() async { + await #expect(processExitsWith: .failure) { + let store = TurnReplayStore() + let sharedState = makeTurnReplayExitTestState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: sharedState + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + await store.register(makeTurnReplayExitTestState(), for: "turn-1") + } + } + + @Test func connectionTerminationClearsAllRawStateAndFailsSubscribers() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let events = try await store.events(for: "turn-1", state: state) + var iterator = events.makeAsyncIterator() + let termination = CodexConnectionTermination.transportFailure( + .io(errno: 32, message: "broken pipe") + ) + + await store.terminateAll(with: termination) + let snapshot = await store.snapshotForTesting() + #expect(snapshot.pendingOperationCount == 0) + #expect(snapshot.activeGenerationCount == 0) + #expect(snapshot.orphanGenerationCount == 0) + #expect(snapshot.termination == termination) + #expect(await state.snapshot() == .terminated(termination)) + do { + _ = try await iterator.next() + Issue.record("Expected the active subscriber to fail on connection termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(termination)) + } catch { + Issue.record("Unexpected active subscriber error: \(error)") + } + } + + @Test func pendingCleanupAfterTerminationReportsTokenAlreadyRemoved() async { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + + await store.terminateAll(with: .closedByCaller) + + #expect(await store.cancelPendingOperation(pending) == .notRegistered) + #expect(await state.snapshot() == .terminated(.closedByCaller)) + } + + @Test func concurrentTerminationAndSubscriptionJoinOneFullCleanup() async throws { + let store = TurnReplayStore() + let state = makeState() + let pending = await store.registerPendingOperation( + kind: .turn(threadID: "thread-1"), + state: state + ) + pending.acceptWrite() + await store.bind( + pending, + to: "turn-1", + initialSnapshot: .init(id: "turn-1", state: .inProgress) + ) + let termination = CodexConnectionTermination.processExited(status: 15) + let firstTermination = Task { + await store.terminateAll(with: termination) + } + let racingEvents = try await store.events(for: "turn-1", state: state) + let secondTermination = Task { + await store.terminateAll(with: termination) + } + await firstTermination.value + await secondTermination.value + + let snapshot = await store.snapshotForTesting() + #expect(snapshot.activeGenerationCount == 0) + #expect(snapshot.pendingOperationCount == 0) + #expect(snapshot.termination == termination) + #expect(await state.snapshot() == .terminated(termination)) + var iterator = racingEvents.makeAsyncIterator() + do { + _ = try await iterator.next() + Issue.record("Expected racing subscription to observe typed termination.") + } catch let error as CodexAppServerError { + #expect(error == .connectionTerminated(termination)) + } catch { + Issue.record("Unexpected racing subscription error: \(error)") + } + } + + private func makeState() -> TurnGenerationHandleState { + TurnGenerationHandleState(connectionLease: makeConnectionLease().0) + } + + private func makeConnectionLease() -> ( + AppServerConnectionLease, + ProcessTerminationToken + ) { + let transport = CodexAppServerTestTransport() + let closeAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + connectionCloseAction: closeAction + ) + let turnReplayStore = TurnReplayStore() + let threadEventHub = ThreadEventHub() + let router = CodexAppServerNotificationRouter( + client: client, + turnReplayStore: turnReplayStore, + threadEventHub: threadEventHub + ) + let connection = AppServerConnection( + transport: transport, + client: client, + router: router, + turnReplayStore: turnReplayStore, + serverRequestHandler: CodexAppServer.Configuration.defaultServerRequestHandler( + clock: .init() + ) + ) + let supervisor = ConnectionSupervisor(connection: connection) + closeAction.bind(to: supervisor) + let terminationToken = ProcessTerminationToken() + return ( + AppServerConnectionLease( + supervisor: supervisor, + processTerminationToken: terminationToken + ), + terminationToken + ) + } + + private func makeCompactSnapshot() -> CompactTurnSnapshot { + let item = makeItem(id: "terminal") + let response = CodexResponse( + turnID: "turn-1", + transcript: .init(items: [item]) + ) + return .init( + snapshot: .init(id: "turn-1", state: .completed, items: [item]), + outcome: .completed(response) + ) + } + + private func makeItem(id: String, text: String? = nil) -> CodexThreadItem { + .init( + id: id, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, text: text ?? id)) + ) + } +} + +private extension TurnGenerationHandleState.Snapshot { + var terminalSnapshot: CompactTurnSnapshot? { + if case .terminal(let compactSnapshot) = self { + return compactSnapshot + } + return nil + } +} + +private final class WeakReference { + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} + +private func makeTurnReplayExitTestState() -> TurnGenerationHandleState { + let transport = CodexAppServerTestTransport() + let closeAction = ConnectionCloseAction() + let client = AppServerClient( + transport: transport, + connectionCloseAction: closeAction + ) + let turnReplayStore = TurnReplayStore() + let threadEventHub = ThreadEventHub() + let router = CodexAppServerNotificationRouter( + client: client, + turnReplayStore: turnReplayStore, + threadEventHub: threadEventHub + ) + let connection = AppServerConnection( + transport: transport, + client: client, + router: router, + turnReplayStore: turnReplayStore, + serverRequestHandler: CodexAppServer.Configuration.defaultServerRequestHandler( + clock: .init() + ) + ) + let supervisor = ConnectionSupervisor(connection: connection) + closeAction.bind(to: supervisor) + return TurnGenerationHandleState(connectionLease: .init( + supervisor: supervisor, + processTerminationToken: .init() + )) +} diff --git a/Tests/CodexDataKitTests/CodexChatObservationChannelTests.swift b/Tests/CodexDataKitTests/CodexChatObservationChannelTests.swift new file mode 100644 index 00000000..9386e40a --- /dev/null +++ b/Tests/CodexDataKitTests/CodexChatObservationChannelTests.swift @@ -0,0 +1,295 @@ +import CodexAppServerKit +import CodexDataKit +import Foundation +import Testing + +@Suite("Chat observation channel") +struct CodexChatObservationChannelTests { + @Test("the 257th pending update compacts to a complete overflow snapshot") + func twoHundredFiftySeventhUpdateCompactsToSnapshot() async throws { + let channel = CodexChatObservationChannel() + let updates = CodexChatUpdates(channel: channel) + var iterator = updates.makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + + let initial = try #require(await iterator.next()) + #expect(initial.sequence == 0) + + for sequence in 1...257 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + + let compacted = try #require(await iterator.next()) + #expect(compacted.sequence == 257) + guard case .snapshot(let snapshot, let reason) = compacted.payload else { + Issue.record("Expected overflow snapshot") + return + } + #expect(reason == .bufferOverflow) + #expect(snapshot.thread.id == "thread-overflow") + #expect(channel.overflowCountForTesting() == 1) + } + + @Test("a fast subscriber is independent from a slow subscriber overflow") + func fastSubscriberIsIndependentFromSlowOverflow() async throws { + let fastChannel = CodexChatObservationChannel() + let slowChannel = CodexChatObservationChannel() + var fast = CodexChatUpdates(channel: fastChannel).makeAsyncIterator() + var slow = CodexChatUpdates(channel: slowChannel).makeAsyncIterator() + fastChannel.seed(snapshotEvent(sequence: 0, reason: .initial)) + slowChannel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await fast.next() + _ = await slow.next() + + for sequence in 1...257 { + let event = updateEvent(sequence: UInt64(sequence)) + let snapshot = snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + fastChannel.yield(event, overflowSnapshot: snapshot) + slowChannel.yield(event, overflowSnapshot: snapshot) + #expect(await fast.next()?.sequence == UInt64(sequence)) + } + + let slowCompaction = try #require(await slow.next()) + #expect(slowCompaction.sequence == 257) + #expect(slowChannel.overflowCountForTesting() == 1) + #expect(fastChannel.overflowCountForTesting() == 0) + } + + @Test("a second full suffix supersedes the previous overflow snapshot") + func secondOverflowSupersedesPreviousSnapshot() async throws { + let channel = CodexChatObservationChannel() + var iterator = CodexChatUpdates(channel: channel).makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await iterator.next() + + for sequence in 1...513 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + + let compacted = try #require(await iterator.next()) + #expect(compacted.sequence == 513) + guard case .snapshot(_, let reason) = compacted.payload else { + Issue.record("Expected second overflow snapshot") + return + } + #expect(reason == .bufferOverflow) + #expect(channel.overflowCountForTesting() == 2) + } + + @Test("a snapshot barrier replaces its prefix and preserves the later suffix") + func snapshotBarrierReplacesPrefix() async throws { + let channel = CodexChatObservationChannel() + var iterator = CodexChatUpdates(channel: channel).makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await iterator.next() + for sequence in 1...3 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + channel.yield( + snapshotEvent(sequence: 4, reason: .refresh), + overflowSnapshot: snapshotEvent(sequence: 4, reason: .refresh) + ) + channel.yield( + updateEvent(sequence: 5), + overflowSnapshot: snapshotEvent(sequence: 5, reason: .bufferOverflow) + ) + + let barrier = try #require(await iterator.next()) + #expect(barrier.sequence == 4) + guard case .snapshot(_, let reason) = barrier.payload else { + Issue.record("Expected refresh snapshot barrier") + return + } + #expect(reason == .refresh) + #expect(await iterator.next()?.sequence == 5) + } + + @Test("a generation restart discards all pending events from the old generation") + func generationRestartDiscardsOldPendingEvents() async throws { + let channel = CodexChatObservationChannel() + var iterator = CodexChatUpdates(channel: channel).makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await iterator.next() + for sequence in 1...3 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + channel.yield( + snapshotEvent(generation: 2, sequence: 0, reason: .generationRestart), + overflowSnapshot: snapshotEvent( + generation: 2, + sequence: 0, + reason: .generationRestart + ) + ) + channel.yield( + updateEvent(generation: 2, sequence: 1), + overflowSnapshot: snapshotEvent( + generation: 2, + sequence: 1, + reason: .bufferOverflow + ) + ) + + let restart = try #require(await iterator.next()) + #expect(restart.generation == 2) + #expect(restart.sequence == 0) + #expect(await iterator.next()?.sequence == 1) + } + + @Test("an upstream failure supersedes buffered events and then finishes") + func upstreamFailureSupersedesBufferAndFinishes() async throws { + let channel = CodexChatObservationChannel() + var iterator = CodexChatUpdates(channel: channel).makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await iterator.next() + for sequence in 1...20 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + channel.supersedeAndFinish(with: snapshotEvent( + sequence: 21, + reason: .upstreamFailure + )) + + let failure = try #require(await iterator.next()) + #expect(failure.sequence == 21) + guard case .snapshot(_, let reason) = failure.payload else { + Issue.record("Expected upstream failure snapshot") + return + } + #expect(reason == .upstreamFailure) + #expect(await iterator.next() == nil) + } + + @Test("release signal deduplicates close and deinit sends by lease ID") + func releaseSignalDeduplicatesLease() async throws { + let signal = ChatObservationReleaseSignal() + let leaseID = UUID() + let firstAcknowledgement = ChatObservationReleaseAcknowledgement() + let duplicateAcknowledgement = ChatObservationReleaseAcknowledgement() + + signal.release(leaseID, acknowledgement: firstAcknowledgement) + signal.release(leaseID, acknowledgement: duplicateAcknowledgement) + + #expect(signal.releasedLeaseCountForTesting() == 1) + let release = try #require(await signal.next()) + #expect(release.leaseID == leaseID) + #expect(duplicateAcknowledgement.isCompletedForTesting() == false) + signal.acknowledge(leaseID) + await firstAcknowledgement.wait() + await duplicateAcknowledgement.wait() + signal.terminate() + #expect(await signal.next() == nil) + } + + @Test("overflow in a mutation batch compacts through the batch's final cursor") + func batchOverflowUsesFinalCursor() async throws { + let channel = CodexChatObservationChannel() + var iterator = CodexChatUpdates(channel: channel).makeAsyncIterator() + channel.seed(snapshotEvent(sequence: 0, reason: .initial)) + _ = await iterator.next() + for sequence in 1...255 { + channel.yield( + updateEvent(sequence: UInt64(sequence)), + overflowSnapshot: snapshotEvent( + sequence: UInt64(sequence), + reason: .bufferOverflow + ) + ) + } + + channel.yield( + [updateEvent(sequence: 256), updateEvent(sequence: 257)], + overflowSnapshot: snapshotEvent(sequence: 257, reason: .bufferOverflow) + ) + channel.yield( + updateEvent(sequence: 258), + overflowSnapshot: snapshotEvent(sequence: 258, reason: .bufferOverflow) + ) + + let compacted = try #require(await iterator.next()) + #expect(compacted.sequence == 257) + guard case .snapshot(_, let reason) = compacted.payload else { + Issue.record("Expected final-cursor overflow snapshot") + return + } + #expect(reason == .bufferOverflow) + #expect(await iterator.next()?.sequence == 258) + } + + @Test("signal termination does not acknowledge a lease before its owner joins") + func terminationDefersAcknowledgementUntilOwnerCompletion() async throws { + let signal = ChatObservationReleaseSignal() + let leaseID = UUID() + let acknowledgement = ChatObservationReleaseAcknowledgement() + signal.release(leaseID, acknowledgement: acknowledgement) + _ = try #require(await signal.next()) + + signal.terminate() + + #expect(acknowledgement.isCompletedForTesting() == false) + signal.completeAllAcknowledgements() + await acknowledgement.wait() + } + + private func updateEvent( + generation: UInt64 = 1, + sequence: UInt64 + ) -> CodexChatObservationEvent { + .init( + generation: generation, + sequence: sequence, + payload: .update(.statusChanged(.idle)) + ) + } + + private func snapshotEvent( + generation: UInt64 = 1, + sequence: UInt64, + reason: CodexChatSnapshotReason + ) -> CodexChatObservationEvent { + .init( + generation: generation, + sequence: sequence, + payload: .snapshot( + .init( + thread: .init(id: "thread-overflow", status: .idle, turns: []), + phase: .idle + ), + reason: reason + ) + ) + } +} diff --git a/Tests/CodexDataKitTests/CodexChatObservationMulticastTests.swift b/Tests/CodexDataKitTests/CodexChatObservationMulticastTests.swift new file mode 100644 index 00000000..82139752 --- /dev/null +++ b/Tests/CodexDataKitTests/CodexChatObservationMulticastTests.swift @@ -0,0 +1,854 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Foundation +import Testing + +@MainActor +struct CodexChatObservationMulticastTests { + @Test("observation updates multicast to multiple consumers") + func observationUpdatesMulticastToMultipleConsumers() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-multicast") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-multicast") + ) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-multicast")) + let observation = try await chat.observe() + let secondObservation = try await chat.observe() + defer { + observation.cancel() + secondObservation.cancel() + } + let firstRecorder = ObservationUpdateRecorder(stream: observation.updates) + let secondRecorder = ObservationUpdateRecorder(stream: secondObservation.updates) + await firstRecorder.waitUntilStarted() + await secondRecorder.waitUntilStarted() + + try await runtime.notificationEmitter.emitItemStarted( + threadID: .init(rawValue: "thread-multicast"), + turnID: .init(rawValue: "turn-multicast"), + item: .agentMessage( + id: "message-multicast", + text: "Multicast update" + ) + ) + + #expect(await firstRecorder.itemInserted(id: "message-multicast") != nil) + #expect(await secondRecorder.itemInserted(id: "message-multicast") != nil) + } + + @Test("typed notification emitter drives item lifecycle and text deltas") + func typedNotificationEmitterDrivesItemLifecycleAndTextDeltas() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID = CodexThreadID(rawValue: "thread-typed-emitter") + let turnID = CodexTurnID(rawValue: "turn-typed-emitter") + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: threadID) + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: threadID) + ) + + let chat = context.model(for: threadID) + let observation = try await chat.observe() + defer { observation.cancel() } + let recorder = ObservationUpdateRecorder(stream: observation.updates) + await recorder.waitUntilStarted() + + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: turnID, + item: .agentMessage( + id: "message-typed-emitter", + text: "Hel", + phase: .finalAnswer + ) + ) + #expect(await observationEventually { + guard let content = chat.items.first?.content, + case .message(let message) = content else { + return false + } + return message.text == "Hel" && message.phase == .finalAnswer + }) + + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: threadID, + turnID: turnID, + itemID: "message-typed-emitter", + delta: "lo" + ) + #expect(await observationEventually { + chat.items.first?.text == "Hello" + }) + + try await runtime.notificationEmitter.emitItemCompleted( + threadID: threadID, + turnID: turnID, + item: .agentMessage(id: "message-typed-emitter", text: "Hello") + ) + #expect(await observationEventually { + chat.items.first?.text == "Hello" + }) + + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: turnID, + item: .commandExecution( + id: "command-typed-emitter", + command: "swift test", + cwd: URL(fileURLWithPath: "/tmp/workspace", isDirectory: true), + status: .inProgress + ) + ) + #expect(await observationEventually { + guard let content = chat.items + .first(where: { $0.itemID == "command-typed-emitter" })?.content, + case .command(let command) = content else { + return false + } + return command.status == .inProgress + }) + #expect(await recorder.itemInserted(id: "command-typed-emitter") != nil) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: threadID, + turnID: turnID, + item: .commandExecution( + id: "command-typed-emitter", + command: "swift test", + cwd: URL(fileURLWithPath: "/tmp/workspace", isDirectory: true), + status: .completed, + aggregatedOutput: "passed", + exitCode: 0 + ) + ) + #expect(await recorder.itemUpdated(id: "command-typed-emitter") != nil) + #expect(await observationEventually { + guard let content = chat.items + .first(where: { $0.itemID == "command-typed-emitter" })?.content, + case .command(let command) = content else { + return false + } + return command.status == .completed && command.output == "passed" + }) + } + + @Test("typed notification fixtures reject invalid required values") + func typedNotificationFixturesRejectInvalidRequiredValues() async throws { + #expect(throws: CodexAppServerTestError.self) { + try CodexAppServerTestItem.agentMessage(id: " ", text: "invalid") + } + #expect(throws: CodexAppServerTestError.self) { + try CodexAppServerTestItem.commandExecution( + id: "command-invalid", + command: "swift test", + cwd: try #require(URL(string: "https://example.com/workspace")), + status: .inProgress + ) + } + let item = try CodexAppServerTestItem.agentMessage(id: "message-valid", text: "Valid") + #expect(throws: CodexAppServerTestError.self) { + try CodexAppServerTestTurn( + snapshot: .init(id: "turn-mismatch", state: .completed, items: []), + items: [item] + ) + } + #expect(throws: CodexAppServerTestError.self) { + try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-in-progress-with-completion", + state: .inProgress, + items: [], + completedAt: Date(timeIntervalSince1970: 20) + ), + items: [] + ) + } + let summaryTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-summary", + state: .completed, + itemsLoadState: .summary, + items: [item.domainProjection] + ), + items: [item] + ) + guard case .object(let turnFields) = summaryTurn.wireValue else { + Issue.record("Expected a canonical turn fixture payload.") + return + } + #expect(turnFields["itemsView"] == .string("summary")) + } + + @Test("typed notification emitter routes specialized current-v2 updates") + func typedNotificationEmitterRoutesSpecializedCurrentV2Updates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID = CodexThreadID(rawValue: "thread-specialized-emitter") + let turnID = CodexTurnID(rawValue: "turn-specialized-emitter") + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: threadID) + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: threadID) + ) + + let chat = context.model(for: threadID) + let observation = try await chat.observe() + defer { observation.cancel() } + let recorder = ObservationUpdateRecorder(stream: observation.updates) + await recorder.waitUntilStarted() + + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: turnID, + item: .reasoning(id: "reasoning-specialized", summary: ["First"]) + ) + try await runtime.notificationEmitter.emitReasoningSummaryPartAdded( + threadID: threadID, + turnID: turnID, + itemID: "reasoning-specialized", + summaryIndex: 1 + ) + try await runtime.notificationEmitter.emitReasoningSummaryTextDelta( + threadID: threadID, + turnID: turnID, + itemID: "reasoning-specialized", + summaryIndex: 1, + delta: "Second" + ) + #expect( + await recorder.itemTextAppended( + id: "reasoning-specialized", + delta: "\n\nSecond" + ) != nil + ) + + let fileChange = CodexFileUpdateChange( + path: "/tmp/workspace/File.swift", + kind: .update(movePath: "/tmp/workspace/Moved.swift"), + diff: "@@ -1 +1 @@" + ) + let movedFileFixture = try CodexAppServerTestItem.fileChange( + id: "file-move-wire", + changes: [fileChange], + status: .inProgress + ) + guard case .object(let fileFields) = movedFileFixture.wireValue, + let changesValue = fileFields["changes"], + case .array(let fileChanges) = changesValue, + let firstValue = fileChanges.first, + case .object(let firstChange) = firstValue, + let kindValue = firstChange["kind"], + case .object(let kindFields) = kindValue else { + Issue.record("Expected a canonical moved-file fixture payload.") + return + } + #expect(kindFields["move_path"] == .string("/tmp/workspace/Moved.swift")) + #expect(kindFields["movePath"] == nil) + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: turnID, + item: .fileChange( + id: "file-specialized", + changes: [.init( + path: fileChange.path, + kind: fileChange.kind, + diff: "" + )], + status: .inProgress + ) + ) + try await runtime.notificationEmitter.emitFileChangePatchUpdated( + threadID: threadID, + turnID: turnID, + itemID: "file-specialized", + changes: [fileChange] + ) + + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: turnID, + item: .mcpToolCall( + id: "mcp-specialized", + server: "review", + tool: "inspect", + status: .inProgress + ) + ) + try await runtime.notificationEmitter.emitMCPToolCallProgress( + threadID: threadID, + turnID: turnID, + itemID: "mcp-specialized", + message: "Reviewing" + ) + + #expect(await observationEventually { + chat.items.first(where: { $0.itemID == "reasoning-specialized" })?.text == "First\n\nSecond" + && chat.items.first(where: { $0.itemID == "file-specialized" })?.text == "@@ -1 +1 @@" + && chat.items.first(where: { $0.itemID == "mcp-specialized" })?.text == "Reviewing" + }) + let completedMCP = try CodexAppServerTestItem.mcpToolCall( + id: "mcp-specialized", + server: "review", + tool: "inspect", + status: .completed, + resultContent: [.string("Done")], + structuredContent: .object(["count": .int(1)]), + resultMetadata: .object(["source": .string("fixture")]) + ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: threadID, + turnID: turnID, + item: completedMCP + ) + #expect(await observationEventually { + guard let content = chat.items + .first(where: { $0.itemID == "mcp-specialized" })?.content, + case .toolCall(let actual) = content, + case .toolCall(let expected) = completedMCP.domainProjection.content else { + return false + } + return actual.result == expected.result && actual.status == .completed + }) + try await runtime.notificationEmitter.emitThreadStatusChanged( + threadID: threadID, + status: .idle + ) + #expect(await observationEventually { chat.status == .idle }) + } + + @Test("typed turn fixture emits terminal current-v2 snapshots") + func typedTurnFixtureEmitsTerminalCurrentV2Snapshots() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID = CodexThreadID(rawValue: "thread-terminal-emitter") + let turnID = CodexTurnID(rawValue: "turn-terminal-emitter") + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: threadID) + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: threadID) + ) + + let chat = context.model(for: threadID) + let observation = try await chat.observe() + defer { observation.cancel() } + let item = try CodexAppServerTestItem.agentMessage( + id: "message-terminal-emitter", + text: "Done", + phase: .finalAnswer + ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: threadID, + turnID: turnID, + item: item + ) + let turn = try CodexAppServerTestTurn( + snapshot: .init( + id: turnID, + state: .completed, + items: [item.domainProjection] + ), + items: [item] + ) + try await runtime.notificationEmitter.emitTurnCompleted( + threadID: threadID, + turn: turn + ) + + #expect(await observationEventually { + guard let content = chat.items.first?.content, + case .message(let message) = content else { + return false + } + return chat.phase == .terminal(turnID: turnID, disposition: .completed) + && message.phase == .finalAnswer + }) + } + + @Test("observed chat advances without update consumers") + func observedChatAdvancesWithoutUpdateConsumers() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-no-consumer") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-no-consumer") + ) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-no-consumer")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ObservationTestThreadItemParams( + threadID: "thread-no-consumer", + turnID: "turn-no-consumer", + item: .init( + id: "message-no-consumer", + type: "agentMessage", + text: "Pump-owned mutation" + ) + ) + ) + + #expect(await observationEventually { + chat.items.map(\.itemID) == ["message-no-consumer"] + && chat.items.map(\.text) == ["Pump-owned mutation"] + }) + } + + @Test("observation publishes mutations before fetched results revalidation suspends") + func observationPublishesBeforeFetchedResultsRevalidation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID = CodexThreadID(rawValue: "thread-revalidation-order") + let storedThread = try observationStoredThread(id: threadID) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(threads: [storedThread])) + let results = context.fetchedResults(for: CodexFetchDescriptor()) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(storedThread) + try await runtime.transport.enqueueThreadRead(storedThread) + let observation = try await chat.observe() + defer { observation.cancel() } + let recorder = ObservationUpdateRecorder(stream: observation.updates) + await recorder.waitUntilStarted() + + let revalidationGate = CodexAppServerTestGate() + try await runtime.transport.enqueueUserVisibleThreadList(.init(threads: [storedThread])) + await runtime.transport.holdNext(.threadList, gate: revalidationGate) + + try await runtime.notificationEmitter.emitItemStarted( + threadID: threadID, + turnID: .init(rawValue: "turn-revalidation-order"), + item: .agentMessage( + id: "message-revalidation-order", + text: "Publish before await" + ) + ) + await revalidationGate.waitUntilBlocked() + + #expect(await recorder.itemInserted(id: "message-revalidation-order") != nil) + + await revalidationGate.open() + } + + @Test("multiple update consumers do not duplicate model mutation") + func multipleUpdateConsumersDoNotDuplicateModelMutation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-no-duplicate") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-no-duplicate") + ) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-no-duplicate")) + let observation = try await chat.observe() + let secondObservation = try await chat.observe() + defer { + observation.cancel() + secondObservation.cancel() + } + let firstRecorder = ObservationUpdateRecorder(stream: observation.updates) + let secondRecorder = ObservationUpdateRecorder(stream: secondObservation.updates) + await firstRecorder.waitUntilStarted() + await secondRecorder.waitUntilStarted() + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ObservationTestThreadItemParams( + threadID: "thread-no-duplicate", + turnID: "turn-no-duplicate", + item: .init( + id: "message-no-duplicate", + type: "agentMessage", + text: "One model item" + ) + ) + ) + + #expect(await firstRecorder.itemInserted(id: "message-no-duplicate") != nil) + #expect(await secondRecorder.itemInserted(id: "message-no-duplicate") != nil) + #expect(chat.items.map(\.itemID) == ["message-no-duplicate"]) + } + + @Test("observation update relay finishes all consumers") + func observationUpdateRelayFinishesAllConsumers() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-finish-multicast") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-finish-multicast") + ) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-finish-multicast")) + let observation = try await chat.observe() + let secondObservation = try await chat.observe() + defer { + observation.cancel() + secondObservation.cancel() + } + let firstRecorder = ObservationUpdateRecorder(stream: observation.updates) + let secondRecorder = ObservationUpdateRecorder(stream: secondObservation.updates) + await firstRecorder.waitUntilStarted() + await secondRecorder.waitUntilStarted() + + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ObservationTestThreadClosedParams(threadID: "thread-finish-multicast") + ) + + #expect(await firstRecorder.waitUntilFinished()) + #expect(await secondRecorder.waitUntilFinished()) + } + + @Test("non-last close releases one lease and last close joins the pump") + func observationCloseHonorsLeaseOwnership() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-close-leases") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-close-leases") + ) + let chat = context.model(for: CodexThreadID(rawValue: "thread-close-leases")) + let first = try await chat.observe() + let second = try await chat.observe() + let firstRecorder = ObservationUpdateRecorder(stream: first.updates) + let secondRecorder = ObservationUpdateRecorder(stream: second.updates) + await firstRecorder.waitUntilStarted() + await secondRecorder.waitUntilStarted() + + await first.close() + #expect(await firstRecorder.waitUntilFinished()) + #expect(await secondRecorder.waitUntilFinished(attempts: 1) == false) + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ObservationTestThreadItemParams( + threadID: "thread-close-leases", + turnID: "turn-close-leases", + item: .init(id: "message-after-close", type: "agentMessage", text: "still live") + ) + ) + #expect(await secondRecorder.itemInserted(id: "message-after-close") != nil) + + await second.close() + #expect(await secondRecorder.waitUntilFinished()) + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-close-leases") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-close-leases") + ) + let restarted = try await chat.observe() + var restartedEvents = restarted.updates.makeAsyncIterator() + let initial = try #require(await restartedEvents.next()) + #expect(initial.generation == 2) + guard case .snapshot(_, let reason) = initial.payload else { + Issue.record("Expected restarted generation snapshot") + return + } + #expect(reason == .generationRestart) + await restarted.close() + } + + @Test("failure before first render yields one complete failure snapshot then finishes") + func setupFailureYieldsSnapshotThenFinishes() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + await runtime.transport.enqueueFailure( + code: -32_000, + message: "offline", + for: "thread/resume" + ) + let chat = context.model(for: CodexThreadID(rawValue: "thread-setup-failure")) + + let observation = try await chat.observe() + var events = observation.updates.makeAsyncIterator() + let failureEvent = try #require(await events.next()) + guard case .snapshot(let snapshot, let reason) = failureEvent.payload else { + Issue.record("Expected failure snapshot") + return + } + #expect(reason == .upstreamFailure) + guard case .failed(.appServer) = snapshot.phase else { + Issue.record("Expected typed app-server failure phase") + return + } + #expect(await events.next() == nil) + await observation.close() + } + + @Test("iterator cancellation releases its lease before a new generation starts") + func iteratorCancellationReleasesLease() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-iterator-cancel") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-iterator-cancel") + ) + let chat = context.model(for: CodexThreadID(rawValue: "thread-iterator-cancel")) + let observation = try await chat.observe() + let consumer = Task { @MainActor in + for await _ in observation.updates {} + } + await Task.yield() + consumer.cancel() + await consumer.value + await observation.close() + + try await runtime.transport.enqueueThreadResume( + try observationStoredThread(id: "thread-iterator-cancel") + ) + try await runtime.transport.enqueueThreadRead( + try observationStoredThread(id: "thread-iterator-cancel") + ) + let restarted = try await chat.observe() + var events = restarted.updates.makeAsyncIterator() + let initial = try #require(await events.next()) + #expect(initial.generation == 2) + guard case .snapshot(_, let reason) = initial.payload else { + Issue.record("Expected generation restart snapshot") + return + } + #expect(reason == .generationRestart) + await restarted.close() + } +} + +private func observationStoredThread( + id: CodexThreadID +) throws -> CodexAppServerTestStoredThread { + let workspace = URL(fileURLWithPath: "/tmp/codex-data-kit-observation", isDirectory: true) + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + preview: id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20), + status: .idle, + ephemeral: false, + turns: [] + ), + turns: [], + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: .appServer + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) +} + +private struct ObservationTestThreadItemParams: Encodable, Sendable { + var threadID: String + var turnID: String + var startedAtMs: Int64 = 0 + var item: Item + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case startedAtMs + case item + } + + struct Item: Encodable, Sendable { + var id: String + var type: String + var text: String + } +} + +private struct ObservationTestThreadClosedParams: Encodable, Sendable { + var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +@MainActor +private func observationEventually( + attempts: Int = 50, + _ condition: @MainActor () async -> Bool +) async -> Bool { + for _ in 0..] = [] + private var isStarted = false + private var isFinished = false + private var task: Task? + + init(stream: CodexChatUpdates) { + task = Task { @MainActor [weak self] in + self?.markStarted() + for await event in stream { + if case .update(let change) = event.payload { + self?.append(change) + } + } + self?.markFinished() + } + } + + deinit { + task?.cancel() + } + + func waitUntilStarted() async { + if isStarted { + return + } + await withCheckedContinuation { continuation in + if isStarted { + continuation.resume() + } else { + startedContinuations.append(continuation) + } + } + } + + func itemInserted(id: String) async -> CodexChatUpdate? { + await next { change in + if case .itemInserted(let item, _, _) = change { + return item.id == id + } + if case .turnInserted(let turn, _) = change { + return turn.items.contains { $0.id == id } + } + return false + } + } + + func itemUpdated(id: String) async -> CodexChatUpdate? { + await next { change in + if case .itemUpdated(let item, _, _) = change { + return item.id == id + } + return false + } + } + + func itemTextAppended(id: String, delta: String) async -> CodexChatUpdate? { + await next { change in + if case .itemTextAppended(let locator, let appendedDelta) = change { + return locator.id == id && appendedDelta == delta + } + return false + } + } + + func waitUntilFinished(attempts: Int = 50) async -> Bool { + if isFinished { + return true + } + for _ in 0.. Bool + ) -> CodexChatUpdate? { + guard let index = changes.firstIndex(where: predicate) else { + return nil + } + return changes.remove(at: index) + } + + private func next( + matching predicate: (CodexChatUpdate) -> Bool + ) async -> CodexChatUpdate? { + for _ in 0..<50 { + if let change = popFirst(matching: predicate) { + return change + } + try? await Task.sleep(for: .milliseconds(10)) + } + return popFirst(matching: predicate) + } +} diff --git a/Tests/CodexDataKitTests/CodexDataKitTests.swift b/Tests/CodexDataKitTests/CodexDataKitTests.swift new file mode 100644 index 00000000..3cd6131b --- /dev/null +++ b/Tests/CodexDataKitTests/CodexDataKitTests.swift @@ -0,0 +1,16061 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Foundation +import Observation +import Synchronization +import Testing + +private func requireEquatable(_: T.Type) {} +private func requireSendable(_: T.Type) {} +private func requireSendableMetatype(_: T.Type) {} +private func requireSerialExecutor(_: T.Type) {} + +@MainActor +private func expectModelIsDetached( + _ operation: @MainActor () async throws -> Void +) async { + do { + try await operation() + Issue.record("Expected modelIsDetached") + } catch let error as CodexModelContextError { + #expect(error == .modelIsDetached) + } catch { + Issue.record("Expected modelIsDetached, got \(error)") + } +} + +private func testWorkspaceID(for url: URL) -> CodexWorkspaceID { + CodexWorkspaceID(rawValue: url.standardizedFileURL.resolvingSymlinksInPath().path) +} + +private func archivedChatPredicate(_ archived: Bool) -> Predicate { + #Predicate { chat in + chat.isArchived == archived + } +} + +private func archivedNotEqualChatPredicate(_ archived: Bool) -> Predicate { + #Predicate { chat in + chat.isArchived != archived + } +} + +private func searchChatPredicate(_ searchTerm: String) -> Predicate { + #Predicate { chat in + chat.isArchived == false + && chat.searchableText.localizedStandardContains(searchTerm) + } +} + +private func modelProviderChatPredicate(_ modelProviders: [String]) -> Predicate { + #Predicate { chat in + chat.isArchived == false + && chat.modelProvider != nil + && modelProviders.contains(chat.modelProvider!) + } +} + +private func providerSearchDisjunctionChatPredicate( + firstProvider: String, + secondProvider: String, + searchTerm: String +) -> Predicate { + let first: String? = firstProvider + let second: String? = secondProvider + return #Predicate { chat in + chat.isArchived == false + && ((chat.modelProvider == first + && chat.searchableText.localizedStandardContains(searchTerm)) + || (chat.modelProvider == second && chat.searchableText.localizedStandardContains(searchTerm)) + ) + } +} + +private func nonNilModelProviderChatPredicate() -> Predicate { + #Predicate { chat in + chat.isArchived == false && chat.modelProvider != nil + } +} + +private func nilSourceKindChatPredicate() -> Predicate { + #Predicate { chat in + chat.isArchived == false && chat.sourceKind == nil + } +} + +private func nonNilSourceKindChatPredicate() -> Predicate { + #Predicate { chat in + chat.isArchived == false && chat.sourceKind != nil + } +} + +private func archivedNilModelProviderChatPredicate() -> Predicate { + #Predicate { chat in + chat.isArchived && chat.modelProvider == nil + } +} + +private func negatedActiveProviderChatPredicate(_ modelProvider: String) -> Predicate { + #Predicate { chat in + !(chat.isArchived == false && chat.modelProvider == modelProvider) + } +} + +private func archivedDoubleSearchChatPredicate( + archived: Bool, + first: String, + second: String +) -> Predicate { + #Predicate { chat in + chat.isArchived == archived + && chat.searchableText.localizedStandardContains(first) + && chat.searchableText.localizedStandardContains(second) + } +} + +private func constantChatPredicate(_ value: Bool) -> Predicate { + #Predicate { _ in + value + } +} + +private func sourceKindChatPredicate(_ sourceKinds: [CodexThreadSourceKind]) -> Predicate { + #Predicate { chat in + chat.isArchived == false + && chat.sourceKind != nil + && sourceKinds.contains(chat.sourceKind!) + } +} + +private func sourceKindEqualityChatPredicate( + _ sourceKind: CodexThreadSourceKind +) -> Predicate { + let optionalSourceKind: CodexThreadSourceKind? = sourceKind + return #Predicate { chat in + chat.isArchived == false && chat.sourceKind == optionalSourceKind + } +} + +private func sourceKindSearchChatPredicate( + _ sourceKind: CodexThreadSourceKind, + searchTerm: String +) -> Predicate { + let optionalSourceKind: CodexThreadSourceKind? = sourceKind + return #Predicate { chat in + chat.isArchived == false + && chat.sourceKind == optionalSourceKind + && chat.searchableText.localizedStandardContains(searchTerm) + } +} + +private func workspaceChatPredicate(_ workspace: URL) -> Predicate { + let workspaceID: CodexWorkspaceID? = testWorkspaceID(for: workspace) + return #Predicate { chat in + chat.isArchived == false && chat.workspaceID == workspaceID + } +} + +private func workspaceChatPredicate(_ workspaces: [URL]) -> Predicate { + let workspaceIDs = workspaces.map(testWorkspaceID(for:)) + return #Predicate { chat in + chat.isArchived == false + && chat.workspaceID != nil + && workspaceIDs.contains(chat.workspaceID!) + } +} + +private func nonOptionalFieldEqualityChatPredicate( + workspace: URL, + modelProvider: String, + sourceKind: CodexThreadSourceKind +) -> Predicate { + let workspaceID = testWorkspaceID(for: workspace) + return #Predicate { chat in + chat.isArchived == false + && chat.workspaceID == workspaceID + && chat.modelProvider == modelProvider + && chat.sourceKind == sourceKind + } +} + +private func archivedSourceKindChatPredicate( + archived: Bool, + sourceKinds: [CodexThreadSourceKind] +) -> Predicate { + #Predicate { chat in + chat.isArchived == archived + && chat.sourceKind != nil + && sourceKinds.contains(chat.sourceKind!) + } +} + +private func workspaceSourceKindChatPredicate( + workspace: URL, + sourceKinds: [CodexThreadSourceKind] +) -> Predicate { + let workspaceID: CodexWorkspaceID? = testWorkspaceID(for: workspace) + return #Predicate { chat in + chat.isArchived == false + && chat.workspaceID == workspaceID + && chat.sourceKind != nil + && sourceKinds.contains(chat.sourceKind!) + } +} + +private func fullThreadListChatPredicate( + archived: Bool, + workspace: URL, + searchTerm: String, + modelProviders: [String], + sourceKinds: [CodexThreadSourceKind] +) -> Predicate { + let workspaceID: CodexWorkspaceID? = testWorkspaceID(for: workspace) + let modelProvider: String? = modelProviders.first + let firstSourceKind: CodexThreadSourceKind? = sourceKinds.first + let secondSourceKind: CodexThreadSourceKind? = sourceKinds.dropFirst().first + return #Predicate { chat in + chat.isArchived == archived + && chat.workspaceID == workspaceID + && chat.searchableText.localizedStandardContains(searchTerm) + && chat.modelProvider == modelProvider + && (chat.sourceKind == firstSourceKind || chat.sourceKind == secondSourceKind) + } +} + +private extension CodexThreadSnapshot { + func withSourceKind(_ sourceKind: CodexThreadSourceKind) -> CodexThreadSnapshot { + CodexThreadSnapshot( + id: id, + workspace: workspace, + name: name, + preview: preview, + modelProvider: modelProvider, + sessionID: sessionID, + parentThreadID: parentThreadID, + source: source, + sourceKind: sourceKind, + gitInfo: gitInfo, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + ephemeral: ephemeral, + turns: turns + ) + } +} + +private actor TestCodexModelActor: CodexModelActor { + nonisolated let modelContainer: CodexModelContainer + nonisolated let modelExecutor: CodexDefaultSerialModelExecutor + + private var chatObservation: CodexChatObservation? + + init(modelContainer: CodexModelContainer) { + self.modelContainer = modelContainer + self.modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: modelContainer) + } + + func fetchRecentChatIDs() async throws -> [CodexThreadID] { + try await modelContext.fetch(CodexFetchDescriptor.recentChats) + .map(\.id) + } + + func startReviewID(in workspace: URL, input: CodexReviewInput) async throws -> CodexThreadID { + let started = try await modelContext.startReview(in: workspace, input: input) + return started.chat.id + } + + func observeChat(_ chatID: CodexThreadID) async throws { + let chat = modelContext.model(for: chatID) + chatObservation = try await chat.observe() + withObservationTracking { + _ = chat.turns + } onChange: { [weak self] in + guard let self else { return } + self.preconditionIsolated( + "Observed chat mutations must run on the context owner's executor." + ) + } + } + + func observedItemTexts(_ chatID: CodexThreadID) -> [String] { + modelContext.model(for: chatID).items.compactMap(\.text) + } + + func cancelChatObservation() { + chatObservation?.cancel() + chatObservation = nil + } + + func observationReleaseSignalForTesting() -> ChatObservationReleaseSignal? { + chatObservation?.releaseSignalForTesting + } +} + +@MainActor +struct CodexModelContextTests { + @Test("model containers and contexts use instance identity equality") + func modelContainerAndContextEqualityUsesInstanceIdentity() async throws { + requireEquatable(CodexModelContainer.self) + requireSendable(CodexModelContainer.self) + requireSendableMetatype(CodexModelContainer.self) + requireEquatable(CodexModelContext.self) + requireSendableMetatype(CodexModelContext.self) + requireSerialExecutor(CodexDefaultSerialModelExecutor.self) + + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let otherContainer = CodexModelContainer(appServer: runtime.server) + + #expect(container == container) + #expect(container != otherContainer) + #expect(container.mainContext == container.mainContext) + #expect(container.mainContext != CodexModelContext(container)) + } + + @Test("container releases its main context without a retain cycle") + func containerReleasesMainContextWithoutRetainCycle() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + weak var weakContainer: CodexModelContainer? + + do { + let container = CodexModelContainer(appServer: runtime.server) + weakContainer = container + _ = container.mainContext + } + + #expect(weakContainer == nil) + } + + @Test("container releases loaded workspace graphs without retain cycles") + func containerReleasesLoadedWorkspaceGraphsWithoutRetainCycles() async throws { + weak var weakContainer: CodexModelContainer? + weak var weakContext: CodexModelContext? + weak var weakGroup: CodexWorkspaceGroup? + weak var weakWorkspace: CodexWorkspace? + weak var weakChat: CodexChat? + + do { + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + weakContainer = container + weakContext = context + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-release", workspace: temporaryDirectory(), name: "Release") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + weakChat = chat + weakWorkspace = chat.workspace + weakGroup = chat.workspace?.workspaceGroup + + #expect(weakGroup != nil) + #expect(weakWorkspace != nil) + #expect(weakChat != nil) + } + + #expect(weakContainer == nil) + #expect(weakContext == nil) + #expect(weakGroup == nil) + #expect(weakWorkspace == nil) + #expect(weakChat == nil) + } + + @Test("model actor creates its own context from a container") + func modelActorCreatesOwnContextFromContainer() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let modelActor = TestCodexModelActor(modelContainer: container) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "model-actor-chat", workspace: temporaryDirectory(), name: "Model Actor") + ])) + + let chatIDs = try await modelActor.fetchRecentChatIDs() + + #expect(chatIDs == [CodexThreadID("model-actor-chat")]) + #expect(container.mainContext.registeredModel(for: CodexThreadID("model-actor-chat")) == nil) + } + + @Test("model actor observations apply live events on the actor context") + func modelActorObservationAppliesLiveEvents() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let modelActor = TestCodexModelActor(modelContainer: container) + let chatID = CodexThreadID("thread-actor-live") + + try await runtime.transport.enqueueThreadResume(.init(id: chatID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [])) + try await runtime.transport.enqueueThreadRead(.init( + id: chatID, + workspace: temporaryDirectory(), + name: "Actor Live" + )) + + try await modelActor.observeChat(chatID) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-actor-live", + turnID: "turn-actor-live" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-actor-live", + turnID: "turn-actor-live", + item: .init( + id: "message-actor-live", + type: "agentMessage", + text: "Live", + phase: "final_answer" + ) + ) + ) + + #expect(await eventually { + await modelActor.observedItemTexts(chatID).contains("Live") + }) + #expect(container.mainContext.registeredModel(for: chatID) == nil) + await modelActor.cancelChatObservation() + } + + @Test("an active observation lease does not retain its model actor") + func activeObservationLeaseDoesNotRetainModelActor() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let chatID = CodexThreadID("thread-actor-release") + weak var weakModelActor: TestCodexModelActor? + var releaseSignal: ChatObservationReleaseSignal? + + try await runtime.transport.enqueueThreadResume(.init(id: chatID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [])) + try await runtime.transport.enqueueThreadRead(.init( + id: chatID, + workspace: temporaryDirectory(), + name: "Actor Release" + )) + + do { + let modelActor = TestCodexModelActor(modelContainer: container) + weakModelActor = modelActor + try await modelActor.observeChat(chatID) + releaseSignal = await modelActor.observationReleaseSignalForTesting() + #expect(releaseSignal?.releasedLeaseCountForTesting() == 0) + } + + #expect(await eventually { weakModelActor == nil }) + let signal = try #require(releaseSignal) + #expect(await eventually { + signal.releasedLeaseCountForTesting() == 1 + && signal.receiverDidCompleteForTesting() + }) + } + + @Test("an observation handle retains its context until the handle is released") + func observationHandleRetainsContextOwner() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let chatID = CodexThreadID("thread-observation-context-owner") + weak var weakContext: CodexModelContext? + var observation: CodexChatObservation? + + try await runtime.transport.enqueueThreadResume(.init(id: chatID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [])) + try await runtime.transport.enqueueThreadRead(.init(id: chatID, turns: [])) + + do { + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + weakContext = context + observation = try await context.model(for: chatID).observe() + } + + #expect(weakContext != nil) + await observation?.close() + observation = nil + #expect(await eventually { weakContext == nil }) + } + + @Test("model actor review starts multicast to the eager main context") + func modelActorReviewStartsMulticastToEagerMainContext() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let modelActor = TestCodexModelActor(modelContainer: container) + + try await runtime.transport.enqueueThreadStart( + threadID: "thread-early-review", + model: "gpt-5" + ) + try await runtime.transport.enqueueReviewStart( + turnID: "turn-early-review", + reviewThreadID: "thread-early-review" + ) + + let reviewChatID = try await modelActor.startReviewID( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + + let mainContext = container.mainContext + let mainChat = try #require(mainContext.registeredModel(for: reviewChatID)) + #expect(mainChat.workspace?.url.path == workspaceURL.path) + } + + @Test("foreign models fail every context-owned operation before app-server I/O") + func foreignModelsFailContextOperationsBeforeAppServerIO() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let owningContext = CodexModelContainer(appServer: runtime.server).mainContext + let foreignContext = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-foreign", workspace: workspaceURL, name: "Foreign") + ])) + let results = owningContext.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + try await results.performFetch() + let chat = try #require(results.items.first) + let workspace = try #require(chat.workspace) + let group = try #require(workspace.workspaceGroup) + + await expectModelIsDetached { try await foreignContext.refresh(group) } + await expectModelIsDetached { try await foreignContext.refresh(workspace) } + await expectModelIsDetached { try await foreignContext.refresh(chat) } + await expectModelIsDetached { _ = try await foreignContext.observe(chat) } + await expectModelIsDetached { _ = try await foreignContext.startChat(in: workspace) } + await expectModelIsDetached { + _ = try await foreignContext.startReview( + in: workspace, + input: .init(target: .uncommittedChanges) + ) + } + await expectModelIsDetached { + _ = try await foreignContext.send(.init("hello"), in: chat) + } + await expectModelIsDetached { try await foreignContext.cancelActiveTurn(in: chat) } + await expectModelIsDetached { try await foreignContext.archive(chat) } + await expectModelIsDetached { try await foreignContext.unarchive(chat) } + await expectModelIsDetached { try await foreignContext.delete(chat) } + + for method in [ + "thread/resume", + "thread/start", + "review/start", + "turn/start", + "turn/interrupt", + "thread/archive", + "thread/unarchive", + "thread/delete", + ] { + #expect(await runtime.transport.recordedRequests(method: method).isEmpty) + } + } + + @Test("parent model refreshes throw after detaching from context") + func parentModelRefreshesThrowAfterDetachingFromContext() async throws { + var detachedWorkspace: CodexWorkspace? + var detachedGroup: CodexWorkspaceGroup? + weak var weakContext: CodexModelContext? + + do { + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + weakContext = context + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-detach", workspace: workspaceURL, name: "Detach") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + guard let workspace = chat.workspace, + let group = workspace.workspaceGroup + else { + Issue.record("Expected fetched chat to have a workspace and group") + return + } + detachedWorkspace = workspace + detachedGroup = group + } + + let workspace = try #require(detachedWorkspace) + let group = try #require(detachedGroup) + #expect(weakContext == nil) + #expect(workspace.modelContext == nil) + #expect(group.modelContext == nil) + + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + do { + try await context.refresh(workspace) + Issue.record("Expected detached workspace refresh to throw") + } catch let error as CodexModelContextError { + #expect(error == .modelIsDetached) + } catch { + Issue.record("Expected modelIsDetached for workspace refresh, got \(error)") + } + + do { + try await context.refresh(group) + Issue.record("Expected detached group refresh to throw") + } catch let error as CodexModelContextError { + #expect(error == .modelIsDetached) + } catch { + Issue.record("Expected modelIsDetached for group refresh, got \(error)") + } + } + + @Test("fetched results use thread/list and mutate existing chat objects") + func fetchedResultsMutateExistingChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let createdAt = Date(timeIntervalSince1970: 1_000) + let updatedAt = Date(timeIntervalSince1970: 2_000) + + try await runtime.transport.enqueueThreadList( + .init(profile: .partialDTO, + threads: [ + .init( + id: "thread-1", + name: "First", + modelProvider: "openai", + sourceKind: .cli, + createdAt: createdAt, + updatedAt: updatedAt, + turns: [.init(id: "turn-1", state: .inProgress)] + ) + ], + nextCursor: "next" + )) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\CodexChat.recencyAt, order: .reverse)] + )) + try await results.performFetch() + + let first = try #require(results.items.first) + let firstTurn = try #require(first.turns.first) + #expect(first.title == "First") + #expect(first.modelProvider == "openai") + #expect(first.createdAt == createdAt) + #expect(first.updatedAt == updatedAt) + #expect(firstTurn.status == CodexTurnStatus.inProgress) + #expect(first.modelContext === context) + #expect(results.nextCursor == "next") + + try await runtime.transport.enqueueThreadList( + .init(profile: .partialDTO, threads: [ + .init( + id: "thread-1", + name: "First renamed", + modelProvider: "openai", + sourceKind: .cli, + createdAt: createdAt, + updatedAt: Date(timeIntervalSince1970: 3_000), + turns: [.init(id: "turn-1", state: .completed)] + ), + .init(id: "thread-2", name: "Second", sourceKind: .cli), + ])) + + try await results.performFetch() + + #expect(results.items.count == 2) + #expect(context.model(for: CodexThreadID(rawValue: "thread-1")) === first) + #expect(results.items.contains { $0 === first }) + #expect(first.title == "First renamed") + #expect(first.turns.first === firstTurn) + #expect(firstTurn.status == CodexTurnStatus.completed) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + } + + @Test("thread provenance mutates in place, preserves omissions, and clears explicit nulls") + func threadProvenanceUsesSnapshotPresenceSemantics() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chatID = CodexThreadID("thread-provenance") + let parentThreadID = CodexThreadID("thread-parent") + let source = CodexThreadSessionSource.subAgent(.threadSpawn(.init( + parentThreadID: parentThreadID, + depth: 2, + agentPath: "research/metadata", + agentNickname: "Ada", + agentRole: "explorer" + ))) + let gitInfo = CodexThreadGitInfo( + sha: "0123456789abcdef", + branch: "feature/provenance", + originURL: "git@github.com:lynnswap/CodexKit.git" + ) + let chat = context.model(for: chatID) + + chat.apply( + .init( + id: chatID, + sessionID: "session-provenance", + parentThreadID: parentThreadID, + source: source, + gitInfo: gitInfo + ), + workspace: nil + ) + + #expect(chat.sessionID == "session-provenance") + #expect(chat.parentThreadID == parentThreadID) + #expect(chat.source == source) + #expect(chat.sourceKind == .subAgentThreadSpawn) + #expect(chat.gitInfo == gitInfo) + + chat.apply(.init(id: chatID), workspace: nil) + + #expect(context.model(for: chatID) === chat) + #expect(chat.sessionID == "session-provenance") + #expect(chat.parentThreadID == parentThreadID) + #expect(chat.source == source) + #expect(chat.sourceKind == .subAgentThreadSpawn) + #expect(chat.gitInfo == gitInfo) + #expect(chat.observationSnapshot().sessionID == "session-provenance") + #expect(chat.observationSnapshot().source == source) + #expect(chat.observationSnapshot().gitInfo == gitInfo) + + chat.apply( + .init( + id: chatID, + turnItemsAreAuthoritative: false, + presentFields: [.sessionID, .parentThreadID, .source, .gitInfo] + ), + workspace: nil + ) + + #expect(context.model(for: chatID) === chat) + #expect(chat.sessionID == nil) + #expect(chat.parentThreadID == nil) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + #expect(chat.gitInfo == nil) + } + + @Test("legacy source-kind snapshots preserve matching exact source metadata") + func legacySourceKindSnapshotsStaySynchronizedWithExactSource() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID("thread-source-compatibility")) + let exactSource = CodexThreadSessionSource.subAgent(.threadSpawn(.init( + parentThreadID: "thread-parent", + depth: 1, + agentPath: nil, + agentNickname: nil, + agentRole: nil + ))) + + chat.apply(.init(id: chat.id, source: exactSource), workspace: nil) + chat.apply(.init(id: chat.id, sourceKind: .subAgentThreadSpawn), workspace: nil) + + #expect(chat.source == exactSource) + #expect(chat.sourceKind == .subAgentThreadSpawn) + + chat.apply(.init(id: chat.id, sourceKind: .appServer), workspace: nil) + + #expect(chat.source == nil) + #expect(chat.sourceKind == .appServer) + + chat.apply( + .init( + id: chat.id, + turnItemsAreAuthoritative: false, + presentFields: [.sourceKind] + ), + workspace: nil + ) + + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + } + + @Test("exact provenance-only list changes revalidate registered fetched results") + func exactProvenanceChangesRevalidateFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{ + "id": "thread-provenance-revalidation", + "sessionId": "session-provenance-revalidation", + "parentThreadId": "thread-parent-revalidation", + "source": {"custom": "atlas"}, + "gitInfo": { + "sha": "1111111111111111", + "branch": "feature/first", + "originUrl": "git@github.com:lynnswap/CodexKit.git" + } + }], + "nextCursor": null + } + """ + ) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + let recorder = FetchedResultsTransactionRecorder(stream: results.transactions) + try await results.performFetch() + #expect(await eventually { recorder.transactions.count == 1 }) + let chat = try #require(results.items.first) + #expect(chat.sessionID == "session-provenance-revalidation") + #expect(chat.parentThreadID == "thread-parent-revalidation") + #expect(chat.source == .custom("atlas")) + #expect(chat.sourceKind == nil) + #expect(chat.gitInfo?.branch == "feature/first") + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{ + "id": "thread-provenance-revalidation", + "sessionId": "session-provenance-revalidation", + "parentThreadId": "thread-parent-revalidation", + "source": {"custom": "chatgpt"}, + "gitInfo": { + "sha": "2222222222222222", + "branch": "feature/second", + "originUrl": "git@github.com:lynnswap/CodexKit.git" + } + }], + "nextCursor": null + } + """ + ) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(await eventually { recorder.transactions.count >= 2 }) + let transaction = try #require(recorder.transactions.first { transaction in + transaction.reason == .revalidate + && transaction.itemChanges.contains( + .update(itemID: chat.id, indexPath: .init(section: 0, item: 0)) + ) + }) + #expect(transaction.reason == .revalidate) + #expect(results.items.first === chat) + #expect(chat.source == .custom("chatgpt")) + #expect(chat.sourceKind == nil) + #expect(chat.gitInfo?.sha == "2222222222222222") + #expect(chat.gitInfo?.branch == "feature/second") + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{"id": "thread-provenance-revalidation"}], + "nextCursor": null + } + """ + ) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(results.items.first === chat) + #expect(chat.sessionID == "session-provenance-revalidation") + #expect(chat.parentThreadID == "thread-parent-revalidation") + #expect(chat.source == .custom("chatgpt")) + #expect(chat.gitInfo?.branch == "feature/second") + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{ + "id": "thread-provenance-revalidation", + "sessionId": null, + "parentThreadId": null, + "source": null, + "gitInfo": null + }], + "nextCursor": null + } + """ + ) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(results.items.isEmpty) + #expect(context.registeredModel(for: chat.id) === chat) + #expect(chat.sessionID == nil) + #expect(chat.parentThreadID == nil) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + #expect(chat.gitInfo == nil) + } + + @Test("exact source pages preserve prior source metadata when a later snapshot omits it") + func exactSourcePagesPreserveOmittedSourceMetadata() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-source-memory", name: "Before") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedSourceKindChatPredicate( + archived: false, + sourceKinds: [.appServer] + ) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadList(.init(profile: .partialDTO, threads: [ + .init(id: "thread-source-memory", name: "After") + ])) + try await results.refresh() + + #expect(results.items.first === chat) + #expect(chat.title == "After") + #expect(chat.source == .appServer) + #expect(chat.sourceKind == .appServer) + } + + @Test("registered chat lookup does not create placeholders") + func registeredChatLookupDoesNotCreatePlaceholders() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID = CodexThreadID(rawValue: "thread-unloaded") + + #expect(context.registeredModel(for: threadID) == nil) + #expect(await runtime.transport.recordedRequests(method: "thread/list").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/read").isEmpty) + + let placeholder = context.model(for: threadID) + + #expect(placeholder.id == threadID) + #expect(placeholder.source == nil) + #expect(placeholder.sourceKind == nil) + #expect(context.registeredModel(for: threadID) === placeholder) + #expect(await runtime.transport.recordedRequests(method: "thread/list").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/read").isEmpty) + } + + @Test("seeded app-server test runtime drives DataKit through public APIs") + func seededAppServerRuntimeDrivesDataKitThroughPublicAPIs() async throws { + let workspace = temporaryDirectory() + let stored = try makeDataKitStoredThreadFixture( + id: "thread-seeded", + workspace: workspace, + name: "Seeded review", + preview: "Loaded from fake app-server", + modelProvider: "gpt-test", + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + let runtime = try await CodexAppServerTestRuntime.start(threads: [ + stored + ]) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + + let chat = try #require(results.items.first) + #expect(chat.title == "Seeded review") + #expect(chat.preview == "Loaded from fake app-server") + #expect(chat.modelProvider == "gpt-test") + #expect(chat.workspace?.url == workspace) + + try await context.refresh(chat, includeTurns: false) + #expect(chat.title == "Seeded review") + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + #expect(await runtime.transport.recordedRequests(method: "thread/read").count == 1) + } + + @Test("seeded app-server test runtime supports starting chats through DataKit") + func seededAppServerRuntimeSupportsStartingChatsThroughDataKit() async throws { + let workspaceURL = temporaryDirectory() + let existing = try makeDataKitStoredThreadFixture( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing" + ) + let plannedStart = try makeDataKitStoredThreadFixture( + id: "thread-started", + workspace: workspaceURL, + model: "gpt-test", + ephemeral: true + ) + let store = try CodexAppServerTestThreadStore( + threads: [existing], + plannedStarts: [plannedStart] + ) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let workspaces = try await context.fetch(CodexFetchDescriptor.workspaces) + let workspace = try #require(workspaces.first) + + let chat = try await workspace.startChat(.init( + options: .init(model: "gpt-test", modelProvider: "openai", ephemeral: true) + )) + let chats = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(chats.first === chat) + #expect(chat.workspace === workspace) + #expect(chat.modelProvider == "openai") + #expect(chat.ephemeral == true) + + let startRequest = try #require( + await runtime.transport.recordedRequests(method: "thread/start").first) + let params = try startRequest.decodeParams(ThreadStartParams.self) + #expect(params.cwd == workspaceURL.path) + #expect(params.model == "gpt-test") + #expect(params.modelProvider == "openai") + #expect(params.ephemeral == true) + } + + @Test("opaque stored-thread refresh keeps review output scoped to its turn") + func opaqueStoredThreadRefreshKeepsReviewOutputScopedToItsTurn() async throws { + let workspace = temporaryDirectory() + let firstItem = try CodexAppServerTestItem.exitedReviewMode( + id: "review-output-first", + review: "First review" + ) + let secondItem = try CodexAppServerTestItem.exitedReviewMode( + id: "review-output-second", + review: "Second review" + ) + let firstTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-first", + state: .completed, + items: [firstItem.domainProjection] + ), + items: [firstItem] + ) + let secondTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-second", + state: .completed, + items: [secondItem.domainProjection] + ), + items: [secondItem] + ) + let stored = try makeDataKitStoredThreadFixture( + id: "thread-review-output", + workspace: workspace, + turns: [firstTurn, secondTurn] + ) + let runtime = try await CodexAppServerTestRuntime.start(threads: [stored]) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await context.refresh(chat, includeTurns: true) + + let firstItemIDs = chat.items(in: firstTurn.snapshot.id).map(\.itemID) + let secondItemIDs = chat.items(in: secondTurn.snapshot.id).map(\.itemID) + #expect(chat.transcript(in: firstTurn.snapshot.id).reviewOutputText == "First review") + #expect(chat.transcript(in: secondTurn.snapshot.id).reviewOutputText == "Second review") + #expect(firstItemIDs == ["review-output-first"]) + #expect(secondItemIDs == ["review-output-second"]) + } + + @Test("opaque stored-thread refresh preserves review rollout semantic metadata") + func opaqueStoredThreadRefreshPreservesReviewRolloutSemanticMetadata() async throws { + let workspace = temporaryDirectory() + let assistant = try CodexAppServerTestItem.agentMessage( + id: "review_rollout_assistant", + text: "review output" + ) + let turn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-review", + state: .completed, + items: [assistant.domainProjection] + ), + items: [assistant] + ) + let stored = try makeDataKitStoredThreadFixture( + id: "thread-review-metadata", + workspace: workspace, + turns: [turn] + ) + let runtime = try await CodexAppServerTestRuntime.start(threads: [stored]) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await context.refresh(chat, includeTurns: true) + + let item = try #require(chat.items(in: turn.snapshot.id).first) + #expect(item.origin == .reviewRolloutAssistant) + #expect(item.semanticRelation == .companionOf(.exitedReviewMode)) + let transcriptItem = try #require(chat.transcript(in: turn.snapshot.id).items.first) + #expect(transcriptItem.origin == .reviewRolloutAssistant) + #expect(transcriptItem.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("fetch requests are translated to app-server thread/list query params") + func fetchRequestTranslatesToThreadListParams() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueThreadList( + CodexAppServerTestThreadPage(threads: []) + ) + + let request = CodexFetchDescriptor( + predicate: fullThreadListChatPredicate( + archived: true, + workspace: workspace, + searchTerm: "needle", + modelProviders: ["gpt-5"], + sourceKinds: [.appServer, .subAgent] + ), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 25 + ) + + _ = try await context.fetch(request) + + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == true) + #expect(params.cursor == nil) + #expect(params.cwd == .paths([workspace.path])) + #expect(params.limit == nil) + #expect(params.searchTerm == nil) + #expect(params.modelProviders == ["gpt-5"]) + #expect(params.sortDirection == "desc") + #expect(params.sortKey == "recency_at") + #expect(params.sourceKinds == ["appServer", "subAgent"]) + #expect(params.useStateDbOnly == nil) + } + + @Test("default chat fetches compose every user-visible source partition") + func defaultChatFetchesComposeUserVisibleSourcePartitions() async throws { + let workspace = temporaryDirectory() + let sources: [(id: String, sourceKind: CodexThreadSourceKind)] = [ + ("thread-cli", .cli), + ("thread-atlas", .init(rawValue: "atlas")), + ("thread-exec", .exec), + ("thread-app-server", .appServer), + ("thread-review", .subAgentReview), + ("thread-compact", .subAgentCompact), + ("thread-spawn", .subAgentThreadSpawn), + ("thread-other", .subAgentOther), + ("thread-unknown", .unknown), + ("thread-memory", .subAgent), + ] + let threads = try sources.enumerated().map { offset, source in + try DataKitTestThreadFixture( + id: .init(rawValue: source.id), + workspace: workspace, + name: source.id, + sourceKind: source.sourceKind, + createdAt: Date(timeIntervalSince1970: Double(offset + 1)), + recencyAt: Date(timeIntervalSince1970: Double(offset + 1)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let chats = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(Set(chats.map(\.id.rawValue)) == [ + "thread-cli", + "thread-atlas", + "thread-exec", + "thread-app-server", + "thread-review", + "thread-compact", + "thread-spawn", + "thread-other", + "thread-unknown", + ]) + #expect(chats.contains { $0.id == "thread-memory" } == false) + let atlas = try #require(chats.first { $0.id == "thread-atlas" }) + #expect(atlas.source == .custom("atlas")) + #expect(atlas.sourceKind == nil) + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.count == 2) + #expect(params[0].sourceKinds == nil) + #expect(params[1].sourceKinds == [ + "exec", + "appServer", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown", + ]) + } + + @Test("default workspace and group fetches compose user-visible source partitions") + func defaultWorkspaceAndGroupFetchesComposeUserVisibleSources() async throws { + let visibleRepo = try gitRepository(named: "Visible") + let memoryRepo = try gitRepository(named: "Memory") + let appServerWorkspace = try createDirectory("App", in: visibleRepo) + let customWorkspace = try createDirectory("Tools", in: visibleRepo) + let memoryWorkspace = try createDirectory("Internal", in: memoryRepo) + let threads = try [ + DataKitTestThreadFixture( + id: "thread-app-server", + workspace: appServerWorkspace, + sourceKind: .appServer + ).storedThread(profile: .currentV2), + DataKitTestThreadFixture( + id: "thread-atlas", + workspace: customWorkspace, + sourceKind: .init(rawValue: "atlas") + ).storedThread(profile: .currentV2), + DataKitTestThreadFixture( + id: "thread-memory", + workspace: memoryWorkspace, + sourceKind: .subAgent + ).storedThread(profile: .currentV2), + ] + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let workspaces = try await context.fetch( + CodexFetchDescriptor.workspaces + ) + let groups = try await context.fetch( + CodexFetchDescriptor.workspaceGroups + ) + + let expectedWorkspaceURLs = Set([ + appServerWorkspace.standardizedFileURL.resolvingSymlinksInPath(), + customWorkspace.standardizedFileURL.resolvingSymlinksInPath(), + ]) + #expect(Set(workspaces.map(\.url)) == expectedWorkspaceURLs) + #expect(groups.count == 1) + #expect(Set(groups.flatMap(\.workspaces).map(\.url)) == expectedWorkspaceURLs) + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.count == 4) + #expect(params[0].sourceKinds == nil) + #expect(params[1].sourceKinds != nil) + #expect(params[2].sourceKinds == nil) + #expect(params[3].sourceKinds != nil) + } + + @Test("unbounded composite fetch accepts sparse responses proven by source partitions") + func unboundedCompositeFetchAcceptsSparsePartitionResponses() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: "thread-newer", + name: "Newer", + updatedAt: Date(timeIntervalSince1970: 200), + recencyAt: Date(timeIntervalSince1970: 200) + ), + .init( + id: "thread-older", + name: "Older", + updatedAt: Date(timeIntervalSince1970: 100), + recencyAt: Date(timeIntervalSince1970: 100) + ), + ] + )) + + let chats = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(chats.map(\.id.rawValue) == ["thread-newer", "thread-older"]) + #expect(chats.allSatisfy { $0.source == nil && $0.sourceKind == nil }) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.count == 2) + #expect(params.allSatisfy { $0.limit == nil }) + } + + @Test("incomplete source and search predicates accept sparse partition members and apply residuals") + func incompleteSourceSearchPredicateAcceptsSparsePartitionMembers() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [ + .init(id: "thread-search-match", name: "needle match"), + .init(id: "thread-search-miss", name: "different"), + ] + )) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindSearchChatPredicate(.appServer, searchTerm: "needle"), + sortBy: [CodexSortDescriptor(\.name)] + )) + + #expect(chats.map(\.id.rawValue) == ["thread-search-match"]) + #expect(chats.first?.source == nil) + #expect(chats.first?.sourceKind == nil) + let request = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first + ) + let params = try request.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["appServer"]) + #expect(params.searchTerm == nil) + } + + @Test("complete server predicates retain authority after sparse source evidence is validated") + func completeServerPredicatesAcceptSparseNonSourceFields() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [.init(id: "thread-complete-sparse", name: "Sparse")] + )) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: nonOptionalFieldEqualityChatPredicate( + workspace: workspace, + modelProvider: "openai", + sourceKind: .appServer + ), + sortBy: [CodexSortDescriptor(\.name)] + )) + + #expect(chats.map(\.id.rawValue) == ["thread-complete-sparse"]) + #expect(chats.first?.workspace?.url == workspace) + #expect(chats.first?.modelProvider == nil) + #expect(chats.first?.source == nil) + #expect(chats.first?.sourceKind == nil) + } + + @Test("created-at composite fallback accepts sparse responses before local paging") + func createdAtCompositeFallbackAcceptsSparseResponses() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: "thread-created-older", + createdAt: Date(timeIntervalSince1970: 100) + ), + .init( + id: "thread-created-newer", + createdAt: Date(timeIntervalSince1970: 200) + ), + ] + )) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.createdAt, order: .reverse)], + fetchLimit: 1 + )) + + #expect(chats.map(\.id.rawValue) == ["thread-created-newer"]) + #expect(chats.first?.source == nil) + #expect(chats.first?.sourceKind == nil) + #expect(context.registeredModel(for: CodexThreadID("thread-created-older")) != nil) + } + + @Test("unsorted composite fallback accepts sparse responses with effective ordering") + func unsortedCompositeFallbackAcceptsSparseResponses() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: "thread-unsorted-older", + createdAt: Date(timeIntervalSince1970: 100) + ), + .init( + id: "thread-unsorted-newer", + createdAt: Date(timeIntervalSince1970: 200) + ), + ] + )) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false) + )) + + #expect(chats.map(\.id.rawValue) == [ + "thread-unsorted-newer", + "thread-unsorted-older", + ]) + #expect(chats.allSatisfy { $0.source == nil && $0.sourceKind == nil }) + } + + @Test("direct complete source pages accept sparse server responses") + func directCompleteSourcePagesAcceptSparseResponses() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: "thread-direct-sparse", + name: "Direct", + recencyAt: Date(timeIntervalSince1970: 100) + ) + ], + nextCursor: "direct-next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-direct-sparse"]) + #expect(results.items.first?.source == nil) + #expect(results.items.first?.sourceKind == nil) + #expect(results.nextCursor == "direct-next") + let request = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first + ) + let params = try request.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["appServer"]) + #expect(params.limit == 10) + } + + @Test("sparse explicit source results survive metadata-only local revalidation") + func sparseExplicitSourceResultsSurviveLocalRevalidation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [ + .init(id: "thread-beta-sparse", name: "Beta"), + .init(id: "thread-zulu-sparse", name: "Zulu"), + ] + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer), + sortBy: [CodexSortDescriptor(\.name)] + )) + + try await results.performFetch() + + let zulu = try #require(results.items.first { $0.id == "thread-zulu-sparse" }) + #expect(results.items.map(\.title) == ["Beta", "Zulu"]) + #expect(zulu.source == nil) + #expect(zulu.sourceKind == nil) + + try await runtime.transport.enqueueThreadResume(.init(id: zulu.id)) + try await runtime.transport.enqueue( + AppServerAPI.Thread.Read.Response(thread: DataKitTestThreadFixture( + id: zulu.id, + name: "Aardvark" + ).dto(profile: .partialDTO)), + for: "thread/read" + ) + try await context.refresh(zulu, includeTurns: false) + + #expect(results.items.map(\.title) == ["Aardvark", "Beta"]) + #expect(results.items.first === zulu) + #expect(Set(results.items.map(\.id)).count == 2) + #expect(zulu.source == nil) + #expect(zulu.sourceKind == nil) + } + + @Test("duplicate direct-page source occurrences return one item") + func duplicateDirectPageSourceOccurrencesReturnOneItem() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [ + .init(id: "thread-direct-duplicate", name: "First", sourceKind: .appServer), + .init(id: "thread-direct-duplicate", name: "Second", sourceKind: .appServer), + ] + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-direct-duplicate"]) + let chat = try #require(results.items.first) + #expect(chat.title == "Second") + #expect(chat.sourceKind == .appServer) + } + + @Test("duplicate partition occurrences do not replace explicit null with omitted proof") + func duplicatePartitionOccurrencesPreserveExplicitNull() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueJSON( + """ + { + "data": [ + {"id": "thread-duplicate-null", "name": "First", "source": null} + ], + "nextCursor": null + } + """, + for: "thread/list" + ) + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [.init(id: "thread-duplicate-null", name: "Second")] + )) + + let chats = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(chats.isEmpty) + let chat = try #require( + context.registeredModel(for: CodexThreadID("thread-duplicate-null")) + ) + #expect(chat.title == "Second") + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + } + + @Test("local live chats require server provenance before local insertion") + func localLiveChatsRequireServerProvenance() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let localChat = context.model(for: CodexThreadID("thread-local-live")) + localChat.apply( + .init(id: localChat.id, status: .active(activeFlags: [])), + workspace: nil + ) + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [] + )) + let descriptor = CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.name)] + ) + let results = context.fetchedResults(for: descriptor) + + try await results.performFetch() + + #expect(results.items.isEmpty) + #expect(context.registeredModel(for: localChat.id) === localChat) + #expect(localChat.status?.isActive == true) + #expect(localChat.source == nil) + #expect(localChat.sourceKind == nil) + + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [.init(id: localChat.id, name: "Server proven")] + )) + _ = try await context.fetch(descriptor) + + #expect(results.items.first === localChat) + #expect(localChat.status?.isActive == true) + #expect(localChat.source == nil) + #expect(localChat.sourceKind == nil) + } + + @Test("bounded composite pages fall back when an invalid prefix hides valid rows") + func boundedCompositePagesFallBackForInvalidPrefixes() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let invalidID = CodexThreadID("thread-bounded-known-null") + let invalidChat = context.model(for: invalidID) + invalidChat.apply( + .init( + id: invalidID, + recencyAt: Date(timeIntervalSince1970: 300), + turnItemsAreAuthoritative: false, + presentFields: [.source, .recencyAt] + ), + workspace: nil + ) + let prefix = try DataKitTestThreadPage( + profile: .partialDTO, + threads: [ + .init( + id: invalidID, + name: "Invalid", + recencyAt: Date(timeIntervalSince1970: 300) + ), + .init( + id: "thread-bounded-valid-1", + name: "Valid 1", + recencyAt: Date(timeIntervalSince1970: 200) + ), + ], + nextCursor: "bounded-invalid-next" + ) + try await runtime.transport.enqueueThreadList(prefix) + try await runtime.transport.enqueueThreadList(.init(profile: .partialDTO, threads: [])) + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: invalidID, + name: "Invalid", + recencyAt: Date(timeIntervalSince1970: 300) + ), + .init( + id: "thread-bounded-valid-1", + name: "Valid 1", + recencyAt: Date(timeIntervalSince1970: 200) + ), + .init( + id: "thread-bounded-valid-2", + name: "Valid 2", + recencyAt: Date(timeIntervalSince1970: 100) + ), + ] + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 2 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == [ + "thread-bounded-valid-1", + "thread-bounded-valid-2", + ]) + #expect(results.nextCursor == nil) + #expect(context.registeredModel(for: invalidID) === invalidChat) + #expect(invalidChat.source == nil) + #expect(invalidChat.sourceKind == nil) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.count == 4) + #expect(params[0].limit == 2) + #expect(params[1].limit == 2) + #expect(params[2].limit == nil) + #expect(params[3].limit == nil) + } + + @Test("bounded composite chat pages use global recency ordering without registering off-page chats") + func boundedCompositeChatPagesUseGlobalRecencyOrdering() async throws { + let workspace = temporaryDirectory() + let threadIDs = (0..<30).map { String(format: "thread-%02d", $0) } + let threads = try threadIDs.enumerated().map { offset, threadID in + try DataKitTestThreadFixture( + id: .init(rawValue: threadID), + workspace: workspace, + name: threadID, + sourceKind: offset.isMultiple(of: 2) ? .cli : .appServer, + recencyAt: Date(timeIntervalSince1970: TimeInterval(1_000 - offset)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs.prefix(10))) + #expect(results.nextCursor == context.localCursor(for: 10)) + for threadID in threadIDs.dropFirst(10) { + #expect(context.registeredModel(for: CodexThreadID(rawValue: threadID)) == nil) + } + + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs.prefix(20))) + #expect(Set(results.items.map(\.id.rawValue)).count == 20) + #expect(results.nextCursor == context.localCursor(for: 20)) + for threadID in threadIDs.dropFirst(20) { + #expect(context.registeredModel(for: CodexThreadID(rawValue: threadID)) == nil) + } + + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == threadIDs) + #expect(Set(results.items.map(\.id.rawValue)).count == 30) + #expect(results.nextCursor == nil) + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 6) + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.map(\.limit) == [10, 10, 20, 20, 30, 30]) + for requestParams in params { + #expect(requestParams.cursor == nil) + #expect(requestParams.archived == false) + #expect(requestParams.sortDirection == "desc") + #expect(requestParams.sortKey == "recency_at") + } + #expect(params[0].sourceKinds == nil) + #expect(params[1].sourceKinds != nil) + #expect(params[2].sourceKinds == nil) + #expect(params[3].sourceKinds != nil) + #expect(params[4].sourceKinds == nil) + #expect(params[5].sourceKinds != nil) + } + + @Test("bounded composite chat pages expose local backwards cursors") + func boundedCompositeChatPagesExposeLocalBackwardsCursors() async throws { + let workspace = temporaryDirectory() + let threadIDs = (0..<30).map { String(format: "thread-%02d", $0) } + let threads = try threadIDs.enumerated().map { offset, threadID in + try DataKitTestThreadFixture( + id: .init(rawValue: threadID), + workspace: workspace, + sourceKind: offset.isMultiple(of: 2) ? .cli : .appServer, + recencyAt: Date(timeIntervalSince1970: TimeInterval(1_000 - offset)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10, + fetchOffset: 10 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs[10..<20])) + #expect(results.nextCursor == context.localCursor(for: 20)) + #expect(results.backwardsCursor == context.localCursor(for: 0)) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.map(\.limit) == [20, 20]) + } + + @Test("bounded composite pages accept source metadata omissions proven by their partition") + func boundedCompositePagesAcceptPartitionProvenSourceOmissions() async throws { + let workspace = temporaryDirectory() + let page = try DataKitTestThreadPage( + profile: .partialDTO, + threads: [ + .init( + id: "thread-newer", + workspace: workspace, + name: "Newer", + recencyAt: Date(timeIntervalSince1970: 200) + ), + .init( + id: "thread-older", + workspace: workspace, + name: "Older", + recencyAt: Date(timeIntervalSince1970: 100) + ), + ], + nextCursor: "interactive-next" + ) + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(page) + try await runtime.transport.enqueueThreadList(.init(profile: .partialDTO, threads: [])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 2 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-newer", "thread-older"]) + #expect(results.nextCursor == context.localCursor(for: 2)) + #expect(results.items.allSatisfy { $0.source == nil && $0.sourceKind == nil }) + for chat in results.items { + #expect(context.registeredModel(for: chat.id) === chat) + #expect(chat.workspace?.url == workspace) + } + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.map(\.limit) == [2, 2]) + #expect(params[0].sourceKinds == nil) + #expect(params[1].sourceKinds != nil) + } + + @Test("bounded composite partitions refill after the server page-size clamp") + func boundedCompositePartitionsRefillAfterServerPageSizeClamp() async throws { + let workspace = temporaryDirectory() + let threadIDs = (0..<130).map { String(format: "thread-%03d", $0) } + let threads = try threadIDs.enumerated().map { offset, threadID in + try DataKitTestThreadFixture( + id: .init(rawValue: threadID), + workspace: workspace, + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: TimeInterval(10_000 - offset)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 125 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs.prefix(125))) + #expect(results.nextCursor == context.localCursor(for: 125)) + for threadID in threadIDs.dropFirst(125) { + #expect(context.registeredModel(for: CodexThreadID(rawValue: threadID)) == nil) + } + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try requests.map { try $0.decodeParams(ThreadListParams.self) } + #expect(params.count == 3) + #expect(params.map(\.limit) == [125, 25, 125]) + #expect(params[0].cursor == nil) + #expect(params[1].cursor != nil) + #expect(params[2].cursor == nil) + #expect(params[0].sourceKinds == nil) + #expect(params[1].sourceKinds == nil) + #expect(params[2].sourceKinds != nil) + } + + @Test("bounded composite page append restores global order around a preserved live chat") + func boundedCompositePageAppendRestoresGlobalOrderAroundPreservedLiveChat() async throws { + let workspace = temporaryDirectory() + let threadIDs = (0..<30).map { String(format: "thread-%02d", $0) } + let threads = try threadIDs.enumerated().map { offset, threadID in + try DataKitTestThreadFixture( + id: .init(rawValue: threadID), + workspace: workspace, + sourceKind: offset.isMultiple(of: 2) ? .cli : .appServer, + recencyAt: Date(timeIntervalSince1970: TimeInterval(1_000 - offset)), + status: offset == 15 ? .active(activeFlags: []) : .idle + ).storedThread(profile: .currentV2) + } + let interactiveThreads = threads.enumerated().compactMap { offset, thread in + offset.isMultiple(of: 2) ? thread : nil + } + let noninteractiveThreads = threads.enumerated().compactMap { offset, thread in + offset.isMultiple(of: 2) ? nil : thread + } + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(threads: [threads[15]])) + let liveChats = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer) + )) + let liveChat = try #require(liveChats.first) + #expect(liveChat.status?.isActive == true) + + try await runtime.transport.enqueueThreadList(.init(threads: interactiveThreads)) + try await runtime.transport.enqueueThreadList(.init(threads: noninteractiveThreads)) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs.prefix(10)) + [threadIDs[15]]) + #expect(results.items.last === liveChat) + + try await runtime.transport.enqueueThreadList(.init(threads: interactiveThreads)) + try await runtime.transport.enqueueThreadList(.init(threads: noninteractiveThreads)) + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs.prefix(20))) + #expect(results.items[15] === liveChat) + } + + @Test("final bounded composite page completes relationship reconciliation") + func finalBoundedCompositePageCompletesRelationshipReconciliation() async throws { + let workspaceURL = temporaryDirectory() + let stale = try DataKitTestThreadFixture( + id: "thread-stale", + workspace: workspaceURL, + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: 400) + ).storedThread(profile: .currentV2) + let first = try DataKitTestThreadFixture( + id: "thread-first", + workspace: workspaceURL, + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: 300) + ).storedThread(profile: .currentV2) + let second = try DataKitTestThreadFixture( + id: "thread-second", + workspace: workspaceURL, + sourceKind: .appServer, + recencyAt: Date(timeIntervalSince1970: 200) + ).storedThread(profile: .currentV2) + let third = try DataKitTestThreadFixture( + id: "thread-third", + workspace: workspaceURL, + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: 100) + ).storedThread(profile: .currentV2) + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(threads: [stale])) + let staleChats = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli) + )) + let staleChat = try #require(staleChats.first) + let workspace = try #require(staleChat.workspace) + + try await runtime.transport.enqueueThreadList(.init( + threads: [first], + nextCursor: "interactive-more" + )) + try await runtime.transport.enqueueThreadList(.init( + threads: [second], + nextCursor: "noninteractive-more" + )) + try await runtime.transport.enqueueThreadList(.init(threads: [first, third])) + try await runtime.transport.enqueueThreadList(.init(threads: [second])) + try await runtime.transport.enqueueThreadList(.init(threads: [first, third])) + try await runtime.transport.enqueueThreadList(.init(threads: [second])) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(workspace.chats.contains { $0 === staleChat }) + + try await results.loadNextPage() + #expect(workspace.chats.contains { $0 === staleChat }) + + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == [ + "thread-first", + "thread-second", + "thread-third", + ]) + #expect(results.nextCursor == nil) + #expect(workspace.chats.contains { $0 === staleChat } == false) + #expect(Set(workspace.chats.map(\.id.rawValue)) == [ + "thread-first", + "thread-second", + "thread-third", + ]) + } + + @Test("final offset composite page preserves relationships before the configured offset") + func finalOffsetCompositePagePreservesPreOffsetRelationships() async throws { + let workspaceURL = temporaryDirectory() + let threadIDs = (0..<30).map { String(format: "thread-%02d", $0) } + let threads = try threadIDs.enumerated().map { offset, threadID in + try DataKitTestThreadFixture( + id: .init(rawValue: threadID), + workspace: workspaceURL, + sourceKind: offset.isMultiple(of: 2) ? .cli : .appServer, + recencyAt: Date(timeIntervalSince1970: TimeInterval(1_000 - offset)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let seed = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + let preOffsetChat = try #require(seed.first) + let workspace = try #require(preOffsetChat.workspace) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10, + fetchOffset: 10 + )) + + try await results.performFetch() + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == Array(threadIDs[10..<30])) + #expect(results.nextCursor == nil) + #expect(workspace.chats.contains { $0 === preOffsetChat }) + } + + @Test("bounded composite fetch applies no snapshots when a later partition fails") + func boundedCompositeFetchIsAtomicAcrossSourcePartitions() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstPartitionChat = try DataKitTestThreadFixture( + id: "thread-first-partition", + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: 1_000) + ).storedThread(profile: .currentV2) + try await runtime.transport.enqueueThreadList(.init(threads: [firstPartitionChat])) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "second partition failed", + for: "thread/list" + ) + + do { + _ = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + Issue.record("Expected the second source partition to fail") + } catch CodexFetchFailure.appServer { + } + + #expect(context.registeredModel(for: firstPartitionChat.snapshot.id) == nil) + let workspace = try #require(firstPartitionChat.snapshot.workspace) + #expect(context.registeredModel(for: testWorkspaceID(for: workspace)) == nil) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 2) + } + + @Test("bounded composite cancellation before apply leaves both partitions unregistered") + func boundedCompositeCancellationBeforeApplyIsAtomic() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstGate = CodexAppServerTestGate() + let secondGate = CodexAppServerTestGate() + let first = try DataKitTestThreadFixture( + id: "thread-first-cancelled", + workspace: temporaryDirectory(), + sourceKind: .cli, + recencyAt: Date(timeIntervalSince1970: 1_000) + ).storedThread(profile: .currentV2) + let second = try DataKitTestThreadFixture( + id: "thread-second-cancelled", + workspace: temporaryDirectory(), + sourceKind: .appServer, + recencyAt: Date(timeIntervalSince1970: 900) + ).storedThread(profile: .currentV2) + + try await runtime.transport.enqueueThreadList(.init(threads: [first])) + try await runtime.transport.enqueueThreadList(.init(threads: [second])) + await runtime.transport.holdNext(method: "thread/list", gate: firstGate) + let fetch = Task { @MainActor in + _ = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 10 + )) + } + + await runtime.transport.waitForRequest(method: "thread/list", count: 1) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/list", + gate: secondGate + ) + await firstGate.open() + await runtime.transport.waitForRequest(method: "thread/list", count: 2) + fetch.cancel() + await secondGate.open() + do { + try await fetch.value + Issue.record("Expected the composite fetch to be cancelled") + } catch is CancellationError { + } + + #expect(context.registeredModel(for: first.snapshot.id) == nil) + #expect(context.registeredModel(for: second.snapshot.id) == nil) + let firstWorkspace = try #require(first.snapshot.workspace) + let secondWorkspace = try #require(second.snapshot.workspace) + #expect(context.registeredModel(for: testWorkspaceID(for: firstWorkspace)) == nil) + #expect(context.registeredModel(for: testWorkspaceID(for: secondWorkspace)) == nil) + } + + @Test("cancelled bounded fallback does not apply exhaustive candidates") + func cancelledBoundedFallbackDoesNotApplyExhaustiveCandidates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let passGate = CodexAppServerTestGate() + let finalGate = CodexAppServerTestGate() + let invalidWorkspace = temporaryDirectory() + let firstWorkspace = temporaryDirectory() + let secondWorkspace = temporaryDirectory() + let fastResponse = + """ + { + "data": [ + { + "id": "thread-fallback-invalid", + "cwd": "\(invalidWorkspace.path)", + "source": null, + "recencyAt": 300 + }, + { + "id": "thread-fallback-first", + "cwd": "\(firstWorkspace.path)", + "recencyAt": 200 + } + ], + "nextCursor": null + } + """ + try await runtime.transport.enqueueJSON(fastResponse, for: "thread/list") + try await runtime.transport.enqueueThreadList(.init(profile: .partialDTO, threads: [])) + try await runtime.transport.enqueueJSON(fastResponse, for: "thread/list") + try await runtime.transport.enqueueThreadList(.init( + profile: .partialDTO, + threads: [ + .init( + id: "thread-fallback-second", + workspace: secondWorkspace, + recencyAt: Date(timeIntervalSince1970: 100) + ) + ] + )) + await passGate.open() + for _ in 0..<3 { + await runtime.transport.holdNext(method: "thread/list", gate: passGate) + } + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/list", + gate: finalGate + ) + let fetch = Task { @MainActor in + _ = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 2 + )) + } + await runtime.transport.waitForRequest(method: "thread/list", count: 4) + + fetch.cancel() + await finalGate.open() + do { + try await fetch.value + Issue.record("Expected the bounded fallback fetch to be cancelled") + } catch is CancellationError { + } + + for threadID in [ + "thread-fallback-invalid", + "thread-fallback-first", + "thread-fallback-second", + ] { + #expect(context.registeredModel(for: CodexThreadID(rawValue: threadID)) == nil) + } + for workspace in [invalidWorkspace, firstWorkspace, secondWorkspace] { + #expect(context.registeredModel(for: testWorkspaceID(for: workspace)) == nil) + } + } + + @Test("composite recency fast path falls back for unbounded and created-at fetches") + func compositeRecencyFastPathFallbacksRemainExhaustive() async throws { + let workspace = temporaryDirectory() + let threads = try (0..<30).map { offset in + try DataKitTestThreadFixture( + id: .init(rawValue: "thread-\(offset)"), + workspace: workspace, + sourceKind: offset.isMultiple(of: 2) ? .cli : .appServer, + createdAt: Date(timeIntervalSince1970: TimeInterval(offset)), + recencyAt: Date(timeIntervalSince1970: TimeInterval(1_000 - offset)) + ).storedThread(profile: .currentV2) + } + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let unbounded = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + let createdAt = try await context.fetch(CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.createdAt, order: .reverse)], + fetchLimit: 5 + )) + + #expect(unbounded.count == 30) + #expect(createdAt.count == 5) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 4) + for request in requests { + let params = try request.decodeParams(ThreadListParams.self) + #expect(params.limit == nil) + #expect(params.sortDirection == "desc") + #expect(params.sortKey == "recency_at") + } + } + + @Test("explicit source predicates keep single-query server paging") + func explicitSourcePredicatesKeepSingleQueryServerPaging() async throws { + let workspace = temporaryDirectory() + let threads = try [ + DataKitTestThreadFixture( + id: "thread-cli", + workspace: workspace, + sourceKind: .cli + ).storedThread(profile: .currentV2), + DataKitTestThreadFixture( + id: "thread-app-server", + workspace: workspace, + sourceKind: .appServer + ).storedThread(profile: .currentV2), + ] + let runtime = try await CodexAppServerTestRuntime.start(threads: threads) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-app-server"]) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 1) + let params = try #require(requests.first).decodeParams(ThreadListParams.self) + #expect(params.limit == 1) + #expect(params.sourceKinds == ["appServer"]) + } + + @Test("fetch requests pass multiple workspace filters to thread list") + func fetchRequestTranslatesMultipleWorkspaceFilters() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let app = temporaryDirectory() + let tools = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + + _ = try await context.fetch(CodexFetchDescriptor( + predicate: workspaceChatPredicate([app, tools]) + )) + + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.cwd == .paths([app.path, tools.path])) + } + + @Test("unsorted composite chat fetches reconstruct the global app-server default") + func unsortedCompositeChatFetchesUseGlobalCreatedAtOrdering() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "recently-active-old-thread", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 400) + ), + .init( + id: "inactive-new-thread", + createdAt: Date(timeIntervalSince1970: 300), + updatedAt: Date(timeIntervalSince1970: 300) + ), + ])) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "noninteractive-middle-thread", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 500) + ) + ])) + + let chats = try await context.fetch(CodexFetchDescriptor()) + + #expect(chats.map(\.id.rawValue) == [ + "inactive-new-thread", + "noninteractive-middle-thread", + "recently-active-old-thread", + ]) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 2) + for request in requests { + let params = try request.decodeParams(ThreadListParams.self) + #expect(params.sortKey == "recency_at") + #expect(params.sortDirection == "desc") + } + } + + @Test("unsorted explicit-source chat fetches preserve app-server ordering") + func unsortedExplicitSourceChatFetchesPreserveAppServerOrdering() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "recently-active-old-thread", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 400) + ), + .init( + id: "inactive-new-thread", + createdAt: Date(timeIntervalSince1970: 300), + updatedAt: Date(timeIntervalSince1970: 300) + ), + ])) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli) + )) + + #expect(chats.map(\.id.rawValue) == [ + "recently-active-old-thread", + "inactive-new-thread", + ]) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 1) + let params = try #require(requests.first).decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["cli"]) + #expect(params.sortKey == nil) + #expect(params.sortDirection == nil) + } + + @Test("localized search predicates are evaluated without server search pushdown") + func localizedSearchPredicatesAreEvaluatedWithoutServerSearchPushdown() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-cafe", name: "Café"), + .init(id: "thread-tea", name: "Tea"), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: searchChatPredicate("cafe"), + fetchLimit: 1 + )) + + #expect(results.map(\.id.rawValue) == ["thread-cafe"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.searchTerm == nil) + #expect(params.limit == nil) + } + + @Test("disjunction unions preserve incomplete local filters") + func disjunctionUnionsPreserveIncompleteLocalFilters() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-openai-match", name: "Café", modelProvider: "openai"), + .init(id: "thread-anthropic-match", name: "Café", modelProvider: "anthropic"), + .init(id: "thread-openai-miss", name: "Tea", modelProvider: "openai"), + .init(id: "thread-other-match", name: "Café", modelProvider: "other"), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: providerSearchDisjunctionChatPredicate( + firstProvider: "openai", + secondProvider: "anthropic", + searchTerm: "cafe" + ), + fetchLimit: 1 + )) + + #expect(results.map(\.id.rawValue) == ["thread-openai-match"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.modelProviders == ["openai", "anthropic"]) + #expect(params.searchTerm == nil) + #expect(params.limit == nil) + } + + @Test("non-optional captured values match optional chat fields") + func nonOptionalCapturedValuesMatchOptionalChatFields() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-match", + workspace: workspace, + name: "Match", + modelProvider: "openai", + sourceKind: .appServer + ) + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: nonOptionalFieldEqualityChatPredicate( + workspace: workspace, + modelProvider: "openai", + sourceKind: .appServer + ) + )) + + #expect(results.map(\.id.rawValue) == ["thread-match"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.cwd == .paths([workspace.path])) + #expect(params.modelProviders == ["openai"]) + #expect(params.sourceKinds == ["appServer"]) + } + + @Test("explicit predicates without archive terms fetch active and archived scopes") + func explicitUnscopedPredicatesFetchBothArchiveScopes() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let provider: String? = "openai" + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-active", + name: "Active", + modelProvider: "openai", + sourceKind: .cli + ) + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-archived", + name: "Archived", + modelProvider: "openai", + sourceKind: .cli + ) + ])) + + let chats = try await context.fetch(CodexFetchDescriptor( + predicate: #Predicate { chat in + chat.modelProvider == provider + } + )) + + #expect(Set(chats.map(\.id.rawValue)) == ["thread-active", "thread-archived"]) + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 4) + #expect(try requests.map { try $0.decodeParams(ThreadListParams.self).archived } == [ + false, + false, + true, + true, + ]) + } + + @Test("created and updated sorts enumerate with the stable recency cursor") + func createdAndUpdatedSortsEnumerateWithStableRecencyCursor() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + + let descriptor = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 25 + ) + _ = try await context.fetch(descriptor) + + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.limit == nil) + #expect(params.sortDirection == "desc") + #expect(params.sortKey == "recency_at") + } + + @Test("string sort descriptors honor their comparator") + func stringSortDescriptorsHonorTheirComparator() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a2", name: "a2"), + .init(id: "thread-a10", name: "a10"), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title, comparator: .lexical)] + )) + + #expect(results.map(\.id.rawValue) == ["thread-a10", "thread-a2"]) + } + + @Test("string sort descriptor comparators affect query signatures") + func stringSortDescriptorComparatorsAffectQuerySignatures() { + let localized = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title, comparator: .localizedStandard)] + ) + let lexical = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title, comparator: .lexical)] + ) + + #expect(localized.querySignature != lexical.querySignature) + } + + @Test("fetch descriptor equality uses its normalized semantic query plan") + func fetchDescriptorEqualityUsesSemanticQueryPlan() { + requireEquatable(CodexFetchDescriptor.self) + let implicitOffset = CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 25 + ) + let explicitOffset = CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 25, + fetchOffset: 0 + ) + let differentOrder = CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .forward)], + fetchLimit: 25 + ) + + #expect(implicitOffset == explicitOffset) + #expect(implicitOffset != differentOrder) + } + + @Test("chat title and name sort descriptors affect query signatures") + func chatTitleAndNameSortDescriptorsAffectQuerySignatures() { + let title = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ) + let name = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + ) + + #expect(title.querySignature != name.querySignature) + } + + @Test("explicit fetch reports typed validation failures before app-server I/O") + func explicitFetchReportsTypedValidationFailures() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + do { + _ = try await context.fetch(CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.preview)] + )) + Issue.record("Expected unsupported sort validation failure") + } catch CodexFetchFailure.validation(.unsupportedSort) { + } + + do { + _ = try await context.fetch(CodexFetchDescriptor(fetchLimit: -1)) + Issue.record("Expected negative fetch limit validation failure") + } catch CodexFetchFailure.validation(.negativeFetchLimit(-1)) { + } + + for predicate in [nilSourceKindChatPredicate(), nonNilSourceKindChatPredicate()] { + do { + _ = try await context.fetch(CodexFetchDescriptor( + predicate: predicate + )) + Issue.record("Expected unsupported source-kind predicate validation failure") + } catch CodexFetchFailure.validation(.unsupportedPredicate) { + } + } + + #expect(await runtime.transport.recordedRequests(method: "thread/list").isEmpty) + } + + @Test("query plan owns every fetched-results mutation strategy") + func queryPlanOwnsMutationStrategies() throws { + let defaultPlan = try CodexThreadQueryPlan( + descriptor: CodexFetchDescriptor() + ) + #expect(defaultPlan.sortPlans.isEmpty) + #expect(defaultPlan.mutationStrategy(for: .insert) == .refreshLoadedWindow) + #expect(defaultPlan.mutationStrategy( + for: .relationshipRefresh + ) == .refreshLoadedWindow) + + let localPlan = try CodexThreadQueryPlan(descriptor: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.title)] + )) + #expect(localPlan.mutationStrategy(for: .insert) == .applyLocally) + #expect(localPlan.mutationStrategy(for: .archive) == .applyLocally) + #expect(localPlan.mutationStrategy( + for: .remove(hasNextPage: false) + ) == .removeLocally) + + let sourcePlan = try CodexThreadQueryPlan(descriptor: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer), + sortBy: [CodexSortDescriptor(\.title)] + )) + #expect(sourcePlan.mutationStrategy(for: .insert) == .applyLocally) + #expect(localPlan.mutationStrategy( + for: .revalidate(affectsMembership: true, hasNextPage: true) + ) == .refreshLoadedWindow) + + let serverOrderedPlan = try CodexThreadQueryPlan(descriptor: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + #expect(serverOrderedPlan.mutationStrategy(for: .insert) == .refreshLoadedWindow) + #expect(serverOrderedPlan.mutationStrategy( + for: .relationshipRefresh + ) == .refreshLoadedWindow) + + let offsetPlan = try CodexThreadQueryPlan(descriptor: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.title)], + fetchOffset: 1 + )) + #expect(offsetPlan.mutationStrategy( + for: .remove(hasNextPage: false) + ) == .refreshLoadedWindow) + } + + @Test("non-nil predicates are filtered before applying local fetch limits") + func nonNilPredicatesFilterBeforeApplyingLocalFetchLimits() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init(id: "thread-without-provider", name: "No Provider", sourceKind: .cli), + .init( + id: "thread-with-provider", + name: "Provider", + modelProvider: "openai", + sourceKind: .cli + ), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: nonNilModelProviderChatPredicate(), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + + #expect(results.map(\.id.rawValue) == ["thread-with-provider"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.limit == nil) + } + + @Test("empty membership predicates are filtered before applying local fetch limits") + func emptyMembershipPredicatesFilterBeforeApplyingLocalFetchLimits() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-openai", name: "OpenAI", modelProvider: "openai"), + .init(id: "thread-anthropic", name: "Anthropic", modelProvider: "anthropic"), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: modelProviderChatPredicate([]), + fetchLimit: 1 + )) + + #expect(results.isEmpty) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.limit == nil) + #expect(params.modelProviders == nil) + } + + @Test("boolean value predicates are evaluated locally") + func booleanValuePredicatesAreEvaluatedLocally() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-visible", name: "Visible") + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + + let trueResults = try await context.fetch(CodexFetchDescriptor( + predicate: constantChatPredicate(true) + )) + + #expect(trueResults.map(\.id.rawValue) == ["thread-visible"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-hidden", name: "Hidden") + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + + let falseResults = try await context.fetch(CodexFetchDescriptor( + predicate: constantChatPredicate(false), + fetchLimit: 1 + )) + + #expect(falseResults.isEmpty) + let recorded = await runtime.transport.recordedRequests(method: "thread/list") + let falseParams = try #require(recorded.last).decodeParams(ThreadListParams.self) + #expect(falseParams.archived == true) + #expect(falseParams.limit == nil) + } + + @Test("archive inequality predicates translate to archived thread list scope") + func archiveInequalityPredicatesTranslateToArchivedThreadListScope() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", name: "Archived", sourceKind: .cli) + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: archivedNotEqualChatPredicate(false) + )) + + #expect(results.map(\.id.rawValue) == ["thread-archived"]) + #expect(results.first?.isArchived == true) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == true) + } + + @Test("nil equality predicates merge with archived thread list scope") + func nilEqualityPredicatesMergeWithArchivedThreadListScope() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init(id: "thread-archived", name: "Archived", sourceKind: .cli) + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: archivedNilModelProviderChatPredicate() + )) + + #expect(results.map(\.id.rawValue) == ["thread-archived"]) + #expect(results.first?.isArchived == true) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == true) + #expect(params.limit == nil) + } + + @Test("archive scopes merge with locally filtered predicates") + func archiveScopesMergeWithLocallyFilteredPredicates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", name: "foo bar"), + .init(id: "thread-partial", name: "foo") + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: archivedDoubleSearchChatPredicate( + archived: false, + first: "foo", + second: "bar" + ) + )) + + #expect(results.map(\.id.rawValue) == ["thread-match"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.archived == false) + #expect(params.searchTerm == nil) + #expect(params.limit == nil) + } + + @Test("negated compound archive predicates fetch both archive scopes") + func negatedCompoundArchivePredicatesFetchBothArchiveScopes() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-active-openai", name: "Active OpenAI", modelProvider: "openai"), + .init( + id: "thread-active-anthropic", + name: "Active Anthropic", + modelProvider: "anthropic" + ), + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived-openai", name: "Archived OpenAI", modelProvider: "openai") + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: negatedActiveProviderChatPredicate("openai"), + sortBy: [CodexSortDescriptor(\.title)] + )) + + #expect(results.map(\.id.rawValue) == [ + "thread-active-anthropic", + "thread-archived-openai", + ]) + #expect(results.map(\.isArchived) == [false, true]) + let recorded = await runtime.transport.recordedRequests(method: "thread/list") + #expect(recorded.count == 4) + let activeParams = try #require(recorded.first).decodeParams(ThreadListParams.self) + let archivedParams = try #require(recorded.last).decodeParams(ThreadListParams.self) + #expect(activeParams.archived == false) + #expect(archivedParams.archived == true) + #expect(activeParams.limit == nil) + #expect(archivedParams.limit == nil) + } + + @Test("canonical app-server session source matches source filters") + func canonicalAppServerSessionSourceMatchesSourceFilters() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-legacy", name: "Legacy", sourceKind: .appServer) + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + )) + + #expect(results.map(\.id.rawValue) == ["thread-legacy"]) + #expect(results.first?.source == .appServer) + #expect(results.first?.sourceKind == .appServer) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["appServer"]) + } + + @Test("canonical sub-agent review session source matches source filters") + func canonicalSubAgentReviewSessionSourceMatchesSourceFilters() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-review", + name: "Review", + sourceKind: .subAgentReview + ) + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.subAgentReview]) + )) + + #expect(results.map(\.id.rawValue) == ["thread-review"]) + #expect(results.first?.source == .subAgent(.review)) + #expect(results.first?.sourceKind == .subAgentReview) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["subAgentReview"]) + } + + @Test("unknown source filters exclude custom session sources") + func unknownSourceFiltersExcludeCustomSessionSources() async throws { + let workspace = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start(threads: [ + try makeDataKitStoredThreadFixture( + id: "thread-custom-source", + workspace: workspace, + source: .custom("automation") + ), + try makeDataKitStoredThreadFixture( + id: "thread-unknown-source", + workspace: workspace, + source: .unknown + ), + ]) + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.unknown]) + )) + + #expect(results.map(\.id.rawValue) == ["thread-unknown-source"]) + #expect(results.first?.source == .unknown) + #expect(results.first?.sourceKind == .unknown) + } + + @Test("broad server sub-agent filters preserve leaf predicate semantics") + func broadServerSubAgentFiltersPreserveLeafPredicateSemantics() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-review", sourceKind: .subAgentReview), + .init(id: "thread-compact", sourceKind: .subAgentCompact), + .init(id: "thread-memory", sourceKind: .subAgent), + ])) + + let results = try await context.fetch(CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.subAgent]) + )) + + #expect(results.map(\.id.rawValue) == ["thread-memory"]) + let recorded = try #require( + await runtime.transport.recordedRequests(method: "thread/list").first) + let params = try recorded.decodeParams(ThreadListParams.self) + #expect(params.sourceKinds == ["subAgent"]) + } + + @Test("query descriptors accept key path sorts and section aliases") + func queryDescriptorsAcceptKeyPathSortsAndSectionAliases() { + let workspaceQuery = CodexQuery(sort: \.name) + let chatQuery = CodexQuery(sort: \.updatedAt, order: .reverse) + let sectionedChatQuery = CodexQuery( + filter: archivedChatPredicate(false), + sort: \.recencyAt, + order: .reverse, + sectionBy: .workspaceGroup + ) + let requestChatQuery = CodexQuery(CodexFetchDescriptor.recentChats) + + #expect(workspaceQuery.wrappedValue.items.isEmpty) + #expect(chatQuery.wrappedValue.items.isEmpty) + #expect(sectionedChatQuery.wrappedValue.items.isEmpty) + #expect(requestChatQuery.wrappedValue.items.isEmpty) + } + + @Test("fetched results emits an initial fetch transaction") + func fetchedResultsEmitsInitialFetchTransaction() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha"), + .init(id: "thread-beta", name: "Beta"), + ])) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + )) + var transactions = results.transactions.makeAsyncIterator() + + try await results.performFetch() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .initialFetch) + #expect(transaction.isInitialFetch) + #expect(transaction.oldSnapshot.sections.isEmpty) + #expect(transaction.newSnapshot.sectionIDs == [.default]) + #expect(transaction.newSnapshot.itemIDs.map(\.rawValue) == ["thread-alpha", "thread-beta"]) + #expect(transaction.sectionChanges == [ + .insert(sectionID: .default, index: 0), + ]) + #expect(transaction.itemChanges == [ + .insert( + itemID: CodexThreadID(rawValue: "thread-alpha"), + indexPath: .init(section: 0, item: 0) + ), + .insert( + itemID: CodexThreadID(rawValue: "thread-beta"), + indexPath: .init(section: 0, item: 1) + ), + ]) + #expect(results.snapshot == transaction.newSnapshot) + #expect(results.items.map(\.id.rawValue) == ["thread-alpha", "thread-beta"]) + #expect(results.sections.first?.items.first === results.items.first) + } + + @Test("fetched-results transactions keep only the newest full snapshot transition") + func fetchedResultsTransactionsBufferNewestSnapshotTransition() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + var iterator = results.transactions.makeAsyncIterator() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", name: "A") + ])) + try await results.performFetch() + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", name: "B") + ])) + try await results.refresh() + + let transaction = try #require(await iterator.next()) + #expect(transaction.reason == .refresh) + #expect(transaction.oldSnapshot.itemIDs.map(\.rawValue) == ["thread-a"]) + #expect(transaction.newSnapshot.itemIDs.map(\.rawValue) == ["thread-b"]) + } + + @Test("workspace-group results emits section and item inserts") + func workspaceGroupControllerEmitsSectionAndItemInserts() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: workspaceURL, name: "Archived") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + let groupID = try #require(workspace.workspaceGroup?.id) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats, + sectionedBy: .workspaceGroup + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + let chat = try await workspace.startChat() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .insert) + #expect(transaction.oldSnapshot.sections.isEmpty) + #expect(transaction.newSnapshot.sectionIDs == [.workspaceGroup(groupID)]) + #expect(transaction.newSnapshot.itemIDs == [chat.id]) + #expect(transaction.sectionChanges == [ + .insert(sectionID: .workspaceGroup(groupID), index: 0), + ]) + #expect(transaction.itemChanges == [ + .insert(itemID: chat.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.first === chat) + #expect(results.sections.first?.items.first === chat) + } + + @Test("workspace-group results emits section and item deletes when archiving") + func workspaceGroupControllerEmitsDeletesWhenArchiving() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats, + sectionedBy: .workspaceGroup + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + + let chat = try #require(results.items.first) + let groupID = try #require(chat.workspace?.workspaceGroup?.id) + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await chat.archive() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .archive) + #expect(transaction.oldSnapshot.sectionIDs == [.workspaceGroup(groupID)]) + #expect(transaction.newSnapshot.sections.isEmpty) + #expect(transaction.sectionChanges == [ + .delete(sectionID: .workspaceGroup(groupID), index: 0), + ]) + #expect(transaction.itemChanges == [ + .delete(itemID: chat.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.isEmpty) + #expect(results.sections.isEmpty) + } + + @Test("unsectioned results emits item and default-section deletes when deleting") + func unsectionedControllerEmitsDeletesWhenDeleting() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", name: "Delete") + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await chat.delete() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .remove) + #expect(transaction.oldSnapshot.sectionIDs == [.default]) + #expect(transaction.newSnapshot.sections.isEmpty) + #expect(transaction.sectionChanges == [ + .delete(sectionID: .default, index: 0), + ]) + #expect(transaction.itemChanges == [ + .delete(itemID: chat.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.isEmpty) + #expect(results.sections.isEmpty) + } + + @Test("workspace results reloads stable rows after chat deletion") + func workspaceControllerReloadsStableRowsAfterChatDeletion() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete"), + .init(id: "thread-keep", workspace: workspaceURL, name: "Keep"), + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor.workspaces + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + let workspace = try #require(results.items.first) + let chat = try #require(workspace.chats.first { $0.id.rawValue == "thread-delete" }) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await chat.delete() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .remove) + #expect(transaction.sectionChanges.isEmpty) + #expect(transaction.itemChanges == [ + .update(itemID: workspace.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.first === workspace) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-keep"]) + } + + @Test("results does not emit moves for item shifts after deletion") + func controllerDoesNotEmitMovesForItemShiftsAfterDeletion() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha"), + .init(id: "thread-beta", name: "Beta"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + )) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + let alpha = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await alpha.delete() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .remove) + #expect(transaction.itemChanges == [ + .delete(itemID: alpha.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.map(\.title) == ["Beta"]) + } + + @Test("workspace-group results does not emit moves for section shifts after deletion") + func workspaceGroupControllerDoesNotEmitMovesForSectionShiftsAfterDeletion() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstRepo = try gitRepository(named: "First") + let secondRepo = try gitRepository(named: "Second") + let firstWorkspaceURL = try createDirectory("App", in: firstRepo) + let secondWorkspaceURL = try createDirectory("App", in: secondRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: firstWorkspaceURL, name: "Alpha"), + .init(id: "thread-beta", workspace: secondWorkspaceURL, name: "Beta"), + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ), + sectionedBy: .workspaceGroup + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + let alpha = try #require(results.items.first) + let firstGroupID = try #require(alpha.workspace?.workspaceGroup?.id) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await alpha.delete() + + let transaction = try #require(await transactions.next()) + #expect(transaction.reason == .remove) + #expect(transaction.sectionChanges == [ + .delete(sectionID: .workspaceGroup(firstGroupID), index: 0), + ]) + #expect(transaction.itemChanges == [ + .delete(itemID: alpha.id, indexPath: .init(section: 0, item: 0)), + ]) + #expect(results.items.map(\.title) == ["Beta"]) + #expect(results.sections.count == 1) + } + + @Test("workspace-group results suppresses no-op moves in mixed refresh diffs") + func workspaceGroupControllerSuppressesNoOpMovesInMixedRefreshDiffs() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstRepo = try gitRepository(named: "First") + let secondRepo = try gitRepository(named: "Second") + let thirdRepo = try gitRepository(named: "Third") + let firstWorkspaceURL = try createDirectory("App", in: firstRepo) + let secondWorkspaceURL = try createDirectory("App", in: secondRepo) + let thirdWorkspaceURL = try createDirectory("App", in: thirdRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: firstWorkspaceURL, name: "Alpha"), + .init(id: "thread-beta", workspace: secondWorkspaceURL, name: "Beta"), + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ), + sectionedBy: .workspaceGroup + ) + var transactions = results.transactions.makeAsyncIterator() + try await results.performFetch() + _ = await transactions.next() + let alpha = try #require(results.items.first { $0.id.rawValue == "thread-alpha" }) + let beta = try #require(results.items.first { $0.id.rawValue == "thread-beta" }) + let firstGroupID = try #require(alpha.workspace?.workspaceGroup?.id) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-gamma", workspace: thirdWorkspaceURL, name: "Aardvark"), + .init(id: "thread-beta", workspace: secondWorkspaceURL, name: "Beta"), + .init(id: "thread-alpha", workspace: firstWorkspaceURL, name: "Zulu"), + ])) + + try await results.refresh() + + let transaction = try #require(await transactions.next()) + let gamma = try #require(results.items.first { $0.id.rawValue == "thread-gamma" }) + let thirdGroupID = try #require(gamma.workspace?.workspaceGroup?.id) + #expect(transaction.reason == .refresh) + #expect(transaction.sectionChanges == [ + .insert(sectionID: .workspaceGroup(thirdGroupID), index: 0), + .move(sectionID: .workspaceGroup(firstGroupID), from: 0, to: 2), + ]) + #expect(transaction.itemChanges == [ + .insert(itemID: gamma.id, indexPath: .init(section: 0, item: 0)), + .update(itemID: beta.id, indexPath: .init(section: 1, item: 0)), + .update(itemID: alpha.id, indexPath: .init(section: 2, item: 0)), + ]) + } + + @Test("workspace-group results emits delete and insert for non-surviving section moves") + func workspaceGroupControllerEmitsDeleteInsertForNonSurvivingSectionMoves() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstRepo = try gitRepository(named: "First") + let secondRepo = try gitRepository(named: "Second") + let firstWorkspaceURL = try createDirectory("App", in: firstRepo) + let secondWorkspaceURL = try createDirectory("App", in: secondRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: firstWorkspaceURL, name: "Move"), + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ), + sectionedBy: .workspaceGroup + ) + let recorder = FetchedResultsTransactionRecorder(stream: results.transactions) + try await results.performFetch() + #expect(await eventually { recorder.transactions.count == 1 }) + let chat = try #require(results.items.first) + let firstGroupID = try #require(chat.workspace?.workspaceGroup?.id) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: secondWorkspaceURL, + name: "Move" + )) + try await context.refresh(chat, includeTurns: false) + + #expect(await eventually { recorder.transactions.count >= 2 }) + let transaction = try #require(recorder.transactions.first { transaction in + transaction.reason == .revalidate + && transaction.oldSnapshot.sectionIDs != transaction.newSnapshot.sectionIDs + }) + let secondGroupID = try #require(chat.workspace?.workspaceGroup?.id) + #expect(transaction.reason == .revalidate) + #expect(transaction.sectionChanges == [ + .delete(sectionID: .workspaceGroup(firstGroupID), index: 0), + .insert(sectionID: .workspaceGroup(secondGroupID), index: 0), + ]) + #expect(transaction.itemChanges == [ + .delete(itemID: chat.id, indexPath: .init(section: 0, item: 0)), + .insert(itemID: chat.id, indexPath: .init(section: 0, item: 0)), + ]) + } + + @Test("results suppresses unrelated revalidation transactions") + func controllerSuppressesUnrelatedRevalidationTransactions() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstRepo = try gitRepository(named: "First") + let secondRepo = try gitRepository(named: "Second") + let firstWorkspaceURL = try createDirectory("App", in: firstRepo) + let secondWorkspaceURL = try createDirectory("App", in: secondRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: firstWorkspaceURL, name: "Alpha"), + .init(id: "thread-beta", workspace: secondWorkspaceURL, name: "Beta"), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + )) + try await allResults.performFetch() + let alpha = try #require(allResults.items.first { $0.id.rawValue == "thread-alpha" }) + let beta = try #require(allResults.items.first { $0.id.rawValue == "thread-beta" }) + let firstWorkspace = try #require(alpha.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: firstWorkspaceURL, name: "Alpha"), + ])) + let results = context.fetchedResults( + for: CodexFetchDescriptor.chats( + in: firstWorkspace, + sortBy: [CodexSortDescriptor(\.title)] + ) + ) + let recorder = FetchedResultsTransactionRecorder(stream: results.transactions) + try await results.performFetch() + #expect(await eventually { recorder.transactions.count == 1 }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-beta")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-beta", + workspace: secondWorkspaceURL, + name: "Beta Updated" + )) + try await context.refresh(beta, includeTurns: false) + + #expect(await recorder.count(after: .milliseconds(20)) == 1) + #expect(results.items.map(\.id) == [alpha.id]) + } + + @Test("results keeps update changes for items that move") + func controllerKeepsUpdateChangesForItemsThatMove() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha"), + .init(id: "thread-beta", name: "Beta"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + )) + let recorder = FetchedResultsTransactionRecorder(stream: results.transactions) + try await results.performFetch() + #expect(await eventually { recorder.transactions.count == 1 }) + let alpha = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-alpha", + name: "Zulu" + )) + try await context.refresh(alpha, includeTurns: false) + + #expect(await eventually { recorder.transactions.count >= 2 }) + let transaction = try #require(recorder.transactions.first { transaction in + transaction.reason == .revalidate + && transaction.itemChanges.contains { + if case .move(let itemID, _, _) = $0 { + return itemID == alpha.id + } + return false + } + }) + #expect(transaction.reason == .revalidate) + #expect(results.items.map(\.title) == ["Beta", "Zulu"]) + #expect(transaction.itemChanges.contains( + .move( + itemID: alpha.id, + from: .init(section: 0, item: 0), + to: .init(section: 0, item: 1) + ) + )) + #expect(transaction.itemChanges.contains( + .update(itemID: alpha.id, indexPath: .init(section: 0, item: 1)) + )) + } + + @Test("fetched chat exposes app-server thread status and recency") + func fetchedChatExposesThreadStatusAndRecency() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let recencyAt = Date(timeIntervalSince1970: 1234) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-active", + name: "Active", + recencyAt: recencyAt, + status: .active(activeFlags: [.waitingOnUserInput]) + ) + ])) + + let chats = try await context.fetch(CodexFetchDescriptor.recentChats) + let chat = try #require(chats.first) + + #expect(chat.recencyAt == recencyAt) + #expect(chat.status == .active(activeFlags: [.waitingOnUserInput])) + } + + @Test("fetched results apply configured fetch offset on initial fetch") + func fetchedResultsApplyConfiguredFetchOffsetOnInitialFetch() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", name: "A"), + .init(id: "thread-b", name: "B"), + ])) + + let request = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1, + fetchOffset: 1 + ) + let results = context.fetchedResults(for: request) + try await results.performFetch() + + #expect(results.items.map(\.title) == ["B"]) + } + + @Test("mutable fetch offsets preserve configured optional values") + func mutableFetchOffsetsPreserveConfiguredOptionalValues() { + var descriptor = CodexFetchDescriptor(fetchOffset: 1) + descriptor.fetchOffset = nil + + var request = CodexFetchDescriptor(fetchOffset: 1) + request.fetchOffset = nil + + #expect(descriptor.fetchOffset == nil) + #expect(request.fetchOffset == nil) + } + + @Test("offset chat fetches do not preserve live chats omitted from the page") + func offsetChatFetchesDoNotPreserveLiveChatsOmittedFromPage() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let liveChat = context.model(for: CodexThreadID(rawValue: "thread-a")) + liveChat.apply( + .init( + id: "thread-a", + name: "A", + status: .active(activeFlags: []) + ), + workspace: nil + ) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", name: "B"), + .init(id: "thread-c", name: "C"), + ])) + + let request = CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1, + fetchOffset: 1 + ) + let results = context.fetchedResults(for: request) + try await results.performFetch() + + #expect(results.items.map(\.title) == ["C"]) + } + + @Test("name-sorted chat pages are sliced after local sorting") + func nameSortedChatPagesAreSlicedAfterLocalSorting() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, + threads: [.init(id: "thread-zulu", workspace: workspace, name: "Zulu")], + nextCursor: "server-next" + )) + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, threads: [.init(id: "thread-alpha", workspace: workspace, name: "Alpha")])) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + + let fetchedWorkspace = try #require(results.items.first?.workspace) + #expect(results.items.map(\.title) == ["Alpha"]) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Zulu"]) + #expect(results.nextCursor?.isEmpty == false) + + let initialRequests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(initialRequests.count == 3) + let firstParams = try #require(initialRequests.first).decodeParams(ThreadListParams.self) + let secondParams = try #require(initialRequests.dropFirst().first) + .decodeParams(ThreadListParams.self) + let partitionParams = try #require(initialRequests.last) + .decodeParams(ThreadListParams.self) + #expect(firstParams.cursor == nil) + #expect(firstParams.limit == nil) + #expect(secondParams.cursor == "server-next") + #expect(secondParams.limit == nil) + #expect(partitionParams.cursor == nil) + #expect(partitionParams.sourceKinds != nil) + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, + threads: [.init(id: "thread-zulu", workspace: workspace, name: "Zulu")], + nextCursor: "server-next" + )) + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, threads: [.init(id: "thread-alpha", workspace: workspace, name: "Alpha")])) + + try await results.loadNextPage() + + #expect(results.items.map(\.title) == ["Alpha", "Zulu"]) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Zulu"]) + #expect(results.nextCursor == nil) + } + + @Test("appended local pages preserve the loaded window backwards cursor") + func appendedLocalPagesPreserveLoadedWindowBackwardsCursor() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + let page = try DataKitTestThreadPage(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-beta", workspace: workspace, name: "Beta"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ]) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(results.items.map(\.title) == ["Alpha"]) + #expect(results.backwardsCursor == nil) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + try await results.loadNextPage() + + #expect(results.items.map(\.title) == ["Alpha", "Beta"]) + #expect(results.backwardsCursor == nil) + } + + @Test("appended local pages preserve live chats omitted from complete relationships") + func appendedLocalPagesPreserveLiveChatsOmittedFromCompleteRelationships() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-running", + workspace: workspace, + name: "A Running", + status: .active(activeFlags: []) + ), + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(results.items.map(\.id.rawValue) == ["thread-running"]) + #expect(results.nextCursor != nil) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue).contains("thread-running")) + #expect(results.items.first?.id.rawValue == "thread-running") + } + + @Test("fetched-results loads serialize and commit one cursor generation") + func fetchedResultsLoadsSerializeCursorGenerations() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let appendGate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a", name: "A")], + nextCursor: "page-2" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a2", name: "A2")] + )) + await runtime.transport.holdNext(method: "thread/list", gate: appendGate) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-b", name: "B")] + )) + + let append = Task { @MainActor in + try await results.loadNextPage() + } + await runtime.transport.waitForRequest(method: "thread/list", count: 2) + + let refresh = Task { @MainActor in + try await results.refresh() + } + await results.waitUntilPendingLoad() + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + + await appendGate.open() + try await append.value + try await refresh.value + + #expect(results.items.map(\.id.rawValue) == ["thread-b"]) + #expect(results.nextCursor == nil) + #expect(results.phase == .loaded) + } + + @Test("concurrent perform-fetch calls derive reasons after serialization") + func concurrentPerformFetchCallsDeriveSerializedReasons() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstFetchGate = CodexAppServerTestGate() + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + let recorder = FetchedResultsTransactionRecorder(stream: results.transactions) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", name: "A") + ])) + await runtime.transport.holdNext(method: "thread/list", gate: firstFetchGate) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", name: "B") + ])) + + let firstFetch = Task { @MainActor in + try await results.performFetch() + } + await runtime.transport.waitForRequest(method: "thread/list", count: 1) + let secondFetch = Task { @MainActor in + try await results.performFetch() + } + await results.waitUntilPendingLoad() + + await firstFetchGate.open() + try await firstFetch.value + try await secondFetch.value + #expect(await eventually { recorder.transactions.count == 2 }) + #expect(recorder.transactions.map(\.reason) == [.initialFetch, .refresh]) + #expect(results.items.map(\.id.rawValue) == ["thread-b"]) + } + + @Test("queued fetched-results load cancellation removes its intent") + func queuedFetchedResultsCancellationRemovesIntent() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let appendGate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a", name: "A")], + nextCursor: "page-2" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a2", name: "A2")] + )) + await runtime.transport.holdNext(method: "thread/list", gate: appendGate) + let append = Task { @MainActor in + try await results.loadNextPage() + } + await runtime.transport.waitForRequest(method: "thread/list", count: 2) + + let refresh = Task { @MainActor in + try await results.refresh() + } + await results.waitUntilPendingLoad() + refresh.cancel() + do { + try await refresh.value + Issue.record("Expected queued refresh cancellation") + } catch is CancellationError { + } + + await appendGate.open() + try await append.value + + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + #expect(results.items.map(\.id.rawValue) == ["thread-a", "thread-a2"]) + #expect(results.phase == .loaded) + } + + @Test("in-flight cancellation discards a cancellation-unaware staged page") + func inFlightFetchedResultsCancellationDiscardsStagedPage() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let appendGate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a", name: "A")], + nextCursor: "page-2" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-b", name: "B")] + )) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/list", + gate: appendGate + ) + let append = Task { @MainActor in + try await results.loadNextPage() + } + await runtime.transport.waitForRequest(method: "thread/list", count: 2) + + append.cancel() + await appendGate.open() + do { + try await append.value + Issue.record("Expected in-flight load cancellation") + } catch is CancellationError { + } + + #expect(results.items.map(\.id.rawValue) == ["thread-a"]) + #expect(results.nextCursor == "page-2") + #expect(results.phase == .loaded) + } + + @Test("fetched-results refresh preserves its loaded page window") + func fetchedResultsRefreshPreservesLoadedWindow() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a1", name: "A1")], + nextCursor: "page-2" + )) + try await results.performFetch() + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a2", name: "A2")], + nextCursor: "page-3" + )) + try await results.loadNextPage() + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-a3", name: "A3")] + )) + try await results.loadNextPage() + #expect(results.items.map(\.id.rawValue) == ["thread-a1", "thread-a2", "thread-a3"]) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-b1", name: "B1")], + nextCursor: "page-2" + )) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-b2", name: "B2")], + nextCursor: "page-3" + )) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-b3", name: "B3")] + )) + + try await results.refresh() + + #expect(results.items.map(\.id.rawValue) == ["thread-b1", "thread-b2", "thread-b3"]) + #expect(results.nextCursor == nil) + } + + @Test("local paged chat load reconciles stale loaded items") + func localPagedChatLoadReconcilesStaleLoadedItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(results.items.map(\.title) == ["Alpha"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-beta", workspace: workspace, name: "Beta"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + try await results.loadNextPage() + + #expect(results.items.map(\.title) == ["Beta", "Zulu"]) + } + + @Test("name-sorted chat pages prune stale workspace relationships from full local results") + func nameSortedChatPagesPruneStaleWorkspaceRelationshipsFromFullLocalResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first?.workspace) + let staleChat = context.model(for: CodexThreadID(rawValue: "thread-zulu")) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Zulu"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-beta", workspace: workspace, name: "Beta"), + ])) + try await results.refresh() + + #expect(results.items.map(\.title) == ["Alpha"]) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Beta"]) + #expect(staleChat.workspace == nil) + } + + @Test("one-shot name-sorted fetches prune stale workspace relationships from full local results") + func oneShotNameSortedFetchesPruneStaleWorkspaceRelationshipsFromFullLocalResults() + async throws + { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + ])) + let allChats = try await context.fetch(CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + )) + let fetchedWorkspace = try #require(allChats.first?.workspace) + let staleChat = context.model(for: CodexThreadID(rawValue: "thread-zulu")) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Zulu"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + .init(id: "thread-beta", workspace: workspace, name: "Beta"), + ])) + let firstPage = try await context.fetch(CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + + #expect(firstPage.map(\.title) == ["Alpha"]) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Beta"]) + #expect(staleChat.workspace == nil) + } + + @Test("one-shot chat fetch notifies registered results after pruning stale chats") + func oneShotChatFetchNotifiesRegisteredResultsAfterPruningStaleChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + let initialPage = try DataKitTestThreadPage(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale") + ]) + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + )) + try await chatResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let groupResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups + ) + try await groupResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(chatResults.items.isEmpty) + #expect(workspaceResults.items.isEmpty) + #expect(workspaceResults.sections.isEmpty) + #expect(groupResults.items.isEmpty) + #expect(groupResults.sections.isEmpty) + } + + @Test("thread list fetch inserts first-seen chats into registered scoped results") + func threadListFetchInsertsFirstSeenChatsIntoRegisteredScopedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let initialPage = try DataKitTestThreadPage(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ]) + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let initialChats = try await context.fetch(CodexFetchDescriptor.recentChats) + let workspace = try #require(initialChats.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let scopedResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: workspace, + sortBy: [CodexSortDescriptor(\.name)] + ), sectionedBy: CodexSectionDescriptor(\.workspaceID)) + try await scopedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing"), + .init(id: "thread-new", workspace: workspaceURL, name: "New"), + ])) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(scopedResults.items.map(\.id.rawValue) == ["thread-existing", "thread-new"]) + #expect(scopedResults.sections.count == 1) + #expect(scopedResults.sections.first?.items.map(\.id.rawValue) == [ + "thread-existing", + "thread-new", + ]) + } + + @Test("workspace-scoped chat fetch applies scoped workspace when snapshots omit cwd") + func workspaceScopedChatFetchAppliesScopedWorkspaceWhenSnapshotsOmitCWD() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [ + { + "id": "thread-new", + "name": "New", + "source": "cli" + } + ] + } + """ + ) + let scopedResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: workspace + )) + try await scopedResults.performFetch() + + let chat = try #require(scopedResults.items.first) + #expect(chat.workspace === workspace) + #expect(workspace.chats.first === chat) + #expect(chat.title == "New") + } + + @Test("workspace and group fetches exclude explicit-null source candidates") + func relationshipFetchesExcludeKnownNullSourceCandidates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let response = + """ + { + "data": [{ + "id": "thread-relationship-null", + "cwd": "\(workspaceURL.path)", + "name": "Known null", + "source": null + }], + "nextCursor": null + } + """ + + try await runtime.transport.enqueueUserVisibleThreadListJSON(response) + let workspaceResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaces + ) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadListJSON(response) + let groupResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups + ) + try await groupResults.performFetch() + + #expect(workspaceResults.items.isEmpty) + #expect(groupResults.items.isEmpty) + let chat = try #require( + context.registeredModel(for: CodexThreadID("thread-relationship-null")) + ) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + } + + @Test("workspace refresh removes candidates whose source becomes explicit null") + func workspaceRefreshExcludesKnownNullSourceCandidates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init(id: "thread-refresh-null", workspace: workspaceURL, name: "Before") + ] + )) + let workspaceResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaces + ) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + let chat = try #require(workspace.chats.first) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{ + "id": "thread-refresh-null", + "cwd": "\(workspaceURL.path)", + "name": "After", + "source": null + }], + "nextCursor": null + } + """ + ) + try await context.refresh(workspace) + + #expect(workspace.chats.isEmpty) + #expect(workspaceResults.items.isEmpty) + #expect(context.registeredModel(for: chat.id) === chat) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + } + + @Test("group refresh removes candidates whose source becomes explicit null") + func groupRefreshExcludesKnownNullSourceCandidates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + try await runtime.transport.enqueueUserVisibleThreadList(.init( + profile: .partialDTO, + threads: [ + .init(id: "thread-group-refresh-null", workspace: workspaceURL, name: "Before") + ] + )) + let groupResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups + ) + try await groupResults.performFetch() + let group = try #require(groupResults.items.first) + let chat = try #require(group.workspaces.first?.chats.first) + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [{ + "id": "thread-group-refresh-null", + "cwd": "\(workspaceURL.path)", + "name": "After", + "source": null + }], + "nextCursor": null + } + """ + ) + try await context.refresh(group) + + #expect(group.workspaces.isEmpty) + #expect(groupResults.items.isEmpty) + #expect(context.registeredModel(for: chat.id) === chat) + #expect(chat.source == nil) + #expect(chat.sourceKind == nil) + } + + @Test("thread list fetch inserts first-seen parents into registered results") + func threadListFetchInsertsFirstSeenParentsIntoRegisteredResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let groupResults = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await groupResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-new", workspace: workspaceURL, name: "New") + ])) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + let workspace = try #require(workspaceResults.items.first) + let group = try #require(groupResults.items.first) + #expect(workspaceResults.items.count == 1) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-new"]) + #expect(groupResults.items.count == 1) + #expect(group.workspaces.contains { $0 === workspace }) + #expect(workspaceResults.sections.count == 1) + #expect(groupResults.sections.count == 1) + } + + @Test("workspace chats preserve fetched chat order") + func workspaceChatsPreserveFetchedChatOrder() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-zulu", workspace: workspace, name: "Zulu"), + .init(id: "thread-alpha", workspace: workspace, name: "Alpha"), + ])) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + )) + try await results.performFetch() + + let fetchedWorkspace = try #require(results.items.first?.workspace) + #expect(results.items.map(\.title) == ["Alpha", "Zulu"]) + #expect(fetchedWorkspace.chats.map(\.title) == ["Alpha", "Zulu"]) + } + + @Test("filtered chat fetches keep previously loaded workspace chats") + func filteredChatFetchesKeepPreviouslyLoadedWorkspaceChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-keep", workspace: workspace, name: "Keep"), + .init(id: "thread-match", workspace: workspace, name: "Match"), + ])) + let allChats = try await context.fetch(CodexFetchDescriptor.recentChats) + let fetchedWorkspace = try #require(allChats.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", workspace: workspace, name: "Match") + ])) + let filteredResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: searchChatPredicate("Match") + )) + try await filteredResults.performFetch() + + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == ["thread-keep", "thread-match"]) + } + + @Test("empty search terms behave like unfiltered chat fetches") + func emptySearchTermsBehaveLikeUnfilteredChatFetches() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale"), + .init(id: "thread-remaining", workspace: workspace, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor()) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await results.refresh() + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let firstParams = try #require(requests.first).decodeParams(ThreadListParams.self) + #expect(firstParams.searchTerm == nil) + #expect(results.items.map(\.id.rawValue) == ["thread-remaining"]) + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("empty source-kind filters behave like unfiltered chat fetches") + func emptySourceKindFiltersBehaveLikeUnfilteredChatFetches() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale"), + .init(id: "thread-remaining", workspace: workspace, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor()) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await results.refresh() + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let firstParams = try #require(requests.first).decodeParams(ThreadListParams.self) + #expect(firstParams.sourceKinds == nil) + #expect(results.items.map(\.id.rawValue) == ["thread-remaining"]) + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("workspace fetches prune chats omitted from the refreshed active list") + func workspaceFetchesPruneChatsOmittedFromRefreshedActiveList() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-keep", workspace: workspace, name: "Keep"), + .init(id: "thread-match", workspace: workspace, name: "Match"), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await allResults.performFetch() + let fetchedWorkspace = try #require(allResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", workspace: workspace, name: "Match") + ])) + let filteredResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await filteredResults.performFetch() + + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == ["thread-match"]) + } + + @Test("unfiltered chat refresh prunes stale workspace chats") + func unfilteredChatRefreshPrunesStaleWorkspaceChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale"), + .init(id: "thread-remaining", workspace: workspace, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first?.workspace) + let staleChat = try #require(results.items.first { $0.id.rawValue == "thread-stale" }) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await results.refresh() + + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + #expect(staleChat.workspace == nil) + } + + @Test("workspace fetch preserves live-only workspace omitted from refresh") + func workspaceFetchPreservesLiveOnlyWorkspaceOmittedFromRefresh() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-running", + workspace: workspace, + name: "Running", + status: .active(activeFlags: []) + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first) + let runningChat = try #require(fetchedWorkspace.chats.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await results.refresh() + + #expect(results.items.map(\.url) == [workspace]) + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-running"]) + #expect(runningChat.workspace === fetchedWorkspace) + } + + @Test("workspace group fetch preserves live-only workspace omitted from refresh") + func workspaceGroupFetchPreservesLiveOnlyWorkspaceOmittedFromRefresh() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository(named: "LiveOnly") + let workspace = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-running", + workspace: workspace, + name: "Running", + status: .active(activeFlags: []) + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await results.performFetch() + let group = try #require(results.items.first) + let fetchedWorkspace = try #require(group.workspaces.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await results.refresh() + + #expect(results.items.map(\.id) == [group.id]) + #expect(group.workspaces.map(\.url) == [workspace]) + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-running"]) + } + + @Test("workspace fetch excludes live-only relationships when pending changes are disabled") + func workspaceFetchExcludesLiveOnlyRelationshipsWhenPendingChangesAreDisabled() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository(named: "NoPending") + let workspace = try createDirectory("App", in: repo) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-running", + workspace: workspace, + name: "Running", + status: .active(activeFlags: []) + ) + ])) + let seedResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await seedResults.performFetch() + let runningChat = try #require( + context.registeredModel(for: CodexThreadID(rawValue: "thread-running")) + ) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor( + includeContextChanges: false + )) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let groupResults = context.fetchedResults(for: CodexFetchDescriptor( + includeContextChanges: false + )) + try await groupResults.performFetch() + + #expect(workspaceResults.items.isEmpty) + #expect(groupResults.items.isEmpty) + #expect(runningChat.workspace?.url == workspace) + } + + @Test("started review prepared threads do not preserve stale fetched chat rows") + func startedReviewPreparedThreadsDoNotPreserveStaleFetchedChatRows() async throws { + let workspace = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + .init( + id: "turn-review", + state: .inProgress, + items: [ + .init( + id: "review-mode", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspace, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + let chat = started.chat + #expect(chat.workspace != nil) + #expect(chat.source == .subAgent(.review)) + #expect(chat.sourceKind == .subAgentReview) + + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-review", + workspace: workspace, + status: .idle + )) + try await context.refresh(chat, includeTurns: false) + #expect(chat.status == .idle) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + + #expect(results.items.isEmpty) + #expect(chat.workspace == nil) + } + + @Test("archived false chat refresh prunes stale workspace chats") + func archivedFalseChatRefreshPrunesStaleWorkspaceChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale"), + .init(id: "thread-remaining", workspace: workspace, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await results.refresh() + + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("chat refresh removes chat from previous workspace when reparented") + func chatRefreshRemovesChatFromPreviousWorkspaceWhenReparented() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let oldWorkspaceURL = temporaryDirectory() + let newWorkspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + let oldWorkspace = try #require(chat.workspace) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: newWorkspaceURL, + name: "Move" + )) + try await context.refresh(chat, includeTurns: false) + + #expect(oldWorkspace.chats.isEmpty) + #expect(chat.workspace?.url == newWorkspaceURL) + #expect(chat.workspace?.chats.first === chat) + } + + @Test("chat refresh revalidates active fetched results") + func chatRefreshRevalidatesActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let oldWorkspaceURL = temporaryDirectory() + let newWorkspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + let chat = try #require(allResults.items.first) + let oldWorkspace = try #require(chat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let oldWorkspaceResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: oldWorkspace + )) + try await oldWorkspaceResults.performFetch() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: newWorkspaceURL, + name: "Move" + )) + try await context.refresh(chat, includeTurns: false) + + #expect(oldWorkspaceResults.items.isEmpty) + #expect(allResults.items.first === chat) + } + + @Test("thread list fetch revalidates workspace scoped fetched results") + func threadListFetchRevalidatesWorkspaceScopedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let oldWorkspaceURL = temporaryDirectory() + let newWorkspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + let chat = try #require(allResults.items.first) + let oldWorkspace = try #require(chat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let oldWorkspaceResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: oldWorkspace + )) + try await oldWorkspaceResults.performFetch() + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: oldWorkspaceURL, name: "Move") + ])) + let sectionedResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + ), sectionedBy: CodexSectionDescriptor(\.workspaceID)) + try await sectionedResults.performFetch() + let oldWorkspaceSectionID = CodexFetchSectionID.workspace(.init(rawValue: oldWorkspaceURL.standardizedFileURL + .resolvingSymlinksInPath() + .path)) + #expect(sectionedResults.sections.first?.id == oldWorkspaceSectionID) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: newWorkspaceURL, name: "Move") + ])) + let fetchedChats = try await context.fetch(CodexFetchDescriptor.recentChats) + let newWorkspaceSectionID = CodexFetchSectionID.workspace(.init(rawValue: newWorkspaceURL.standardizedFileURL + .resolvingSymlinksInPath() + .path)) + + #expect(fetchedChats.first === chat) + #expect(chat.workspace?.url == newWorkspaceURL) + #expect(oldWorkspaceResults.items.isEmpty) + #expect(sectionedResults.items.first === chat) + #expect(sectionedResults.sections.first?.id == newWorkspaceSectionID) + } + + @Test("thread list fetch revalidates metadata sorted fetched results") + func threadListFetchRevalidatesMetadataSortedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let initialPage = try DataKitTestThreadPage(profile: .currentV2, threads: [ + .init( + id: "thread-alpha", + workspace: workspaceURL, + name: "Alpha", + updatedAt: Date(timeIntervalSince1970: 1_000) + ), + .init( + id: "thread-zulu", + workspace: workspaceURL, + name: "Zulu", + updatedAt: Date(timeIntervalSince1970: 2_000) + ), + ]) + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let nameResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + )) + try await nameResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let updatedResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await updatedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(initialPage) + let sectionedNameResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + ), sectionedBy: CodexSectionDescriptor(\.workspaceID)) + try await sectionedNameResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-alpha", + workspace: workspaceURL, + name: "Omega", + updatedAt: Date(timeIntervalSince1970: 3_000) + ), + .init( + id: "thread-zulu", + workspace: workspaceURL, + name: "Aardvark", + updatedAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + _ = try await context.fetch(CodexFetchDescriptor.recentChats) + + #expect(nameResults.items.map(\.title) == ["Aardvark", "Omega"]) + #expect(updatedResults.items.map(\.title) == ["Omega", "Aardvark"]) + #expect(sectionedNameResults.items.map(\.title) == ["Aardvark", "Omega"]) + #expect(sectionedNameResults.sections.first?.items.map(\.title) == ["Aardvark", "Omega"]) + } + + @Test("chat refresh preserves archived fetched result membership") + func chatRefreshPreservesArchivedFetchedResultMembership() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: workspaceURL, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let chat = try #require(archivedResults.items.first) + #expect(chat.isArchived) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let activeResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await activeResults.performFetch() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-archived")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-archived", + workspace: workspaceURL, + name: "Archived" + )) + try await context.refresh(chat, includeTurns: false) + + #expect(archivedResults.items.first === chat) + #expect(activeResults.items.isEmpty) + } + + @Test("archived fetch revalidates active fetched result membership") + func archivedFetchRevalidatesActiveFetchedResultMembership() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let activeResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await activeResults.performFetch() + let chat = try #require(activeResults.items.first) + #expect(chat.isArchived == false) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + + #expect(chat.isArchived) + #expect(activeResults.items.isEmpty) + #expect(archivedResults.items.first === chat) + } + + @Test("chat refresh preserves server-only filtered fetched results") + func chatRefreshPreservesServerOnlyFilteredFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-source", workspace: workspaceURL, name: "Source") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-source", + workspace: workspaceURL, + name: "Source" + )) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture( + id: "thread-source", + workspace: workspaceURL, + name: "Source" + ).withSourceKind(.appServer) + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.first === chat) + } + + @Test("source-filtered results preserve matching live chats omitted from thread list") + func sourceFilteredResultsPreserveMatchingLiveChatsOmittedFromThreadList() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture( + id: "thread-live-source", + workspace: workspaceURL, + name: "Live source", + status: .active(activeFlags: []) + ).withSourceKind(.appServer) + ])) + let descriptor = CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer) + ) + let results = context.fetchedResults(for: descriptor) + try await results.performFetch() + let chat = try #require(results.items.first) + #expect(context.preservedLiveChats( + omittedFrom: [CodexChat](), + descriptor: descriptor + ).first === chat) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + try await results.refresh() + + #expect(results.items.first === chat) + } + + @Test("default results do not preserve active memory-consolidation chats") + func defaultResultsExcludeObservedActiveMemoryChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository(named: "Memory") + let workspaceURL = try createDirectory("Internal", in: repo) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-memory", + workspace: workspaceURL, + name: "Memory", + sourceKind: .subAgent, + status: .active(activeFlags: []) + ) + ])) + let memoryResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.subAgent) + )) + try await memoryResults.performFetch() + let memoryChat = try #require(memoryResults.items.first) + let memoryWorkspace = try #require(memoryChat.workspace) + let memoryGroup = try #require(memoryWorkspace.workspaceGroup) + #expect(memoryChat.source == .subAgent(.memoryConsolidation)) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(threads: [])) + let defaultResults = context.fetchedResults( + for: CodexFetchDescriptor.recentChats + ) + try await defaultResults.performFetch() + + #expect(defaultResults.items.isEmpty) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(threads: [])) + let groupResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups + ) + try await groupResults.performFetch() + + #expect(groupResults.items.isEmpty) + #expect(memoryWorkspace.chats.isEmpty) + #expect(memoryGroup.workspaces.isEmpty) + #expect(memoryResults.items.first === memoryChat) + } + + @Test("default empty sorting orders preserved live chats by creation date") + func defaultEmptySortOrdersPreservedLiveChatsByCreationDate() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let newerDate = Date(timeIntervalSince1970: 200) + let olderDate = Date(timeIntervalSince1970: 100) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-active-newer", + workspace: workspaceURL, + name: "Newer", + sourceKind: .appServer, + createdAt: newerDate, + status: .active(activeFlags: []) + ) + ])) + let sourceResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.appServer) + )) + try await sourceResults.performFetch() + let activeChat = try #require(sourceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-older", + workspace: workspaceURL, + name: "Older", + sourceKind: .cli, + createdAt: olderDate + ) + ])) + let descriptor = CodexFetchDescriptor() + let defaultResults = context.fetchedResults(for: descriptor) + try await defaultResults.performFetch() + + #expect(defaultResults.items.map(\.id.rawValue) == [ + "thread-active-newer", + "thread-older", + ]) + #expect(context.sortedItems( + Array(defaultResults.items.reversed()), + for: descriptor + ).map(\.id.rawValue) == [ + "thread-active-newer", + "thread-older", + ]) + #expect(defaultResults.items.first === activeChat) + } + + @Test("chat refresh rebuilds server-only filtered sections") + func chatRefreshRebuildsServerOnlyFilteredSections() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let oldWorkspaceURL = temporaryDirectory() + let newWorkspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-source", workspace: oldWorkspaceURL, name: "Source") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + ), sectionedBy: CodexSectionDescriptor(\.workspaceID)) + try await results.performFetch() + let chat = try #require(results.items.first) + #expect( + results.sections.first?.id == .workspace(.init(rawValue: oldWorkspaceURL.standardizedFileURL + .resolvingSymlinksInPath().path)) + ) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-source")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-source", + workspace: newWorkspaceURL, + name: "Source" + )) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture( + id: "thread-source", + workspace: newWorkspaceURL, + name: "Source" + ).withSourceKind(.appServer) + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.first === chat) + #expect( + results.sections.first?.id == .workspace(.init(rawValue: newWorkspaceURL.standardizedFileURL + .resolvingSymlinksInPath().path)) + ) + } + + @Test("recency sort applies a same-direction stable thread-ID tie-break") + func recencySortAppliesStableThreadIDTieBreak() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-server-first", + name: "Server first", + updatedAt: Date(timeIntervalSince1970: 1_000) + ), + .init( + id: "thread-server-second", + name: "Server second", + updatedAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + + let results = context.fetchedResults( + for: CodexFetchDescriptor(sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)]) + ) + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-server-second", "thread-server-first"]) + } + + @Test("secondary descriptors are applied after primary recency values tie") + func secondaryDescriptorsApplyAfterPrimaryRecencyTies() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-zulu", name: "Zulu"), + .init(id: "thread-alpha", name: "Alpha"), + ])) + + let results = context.fetchedResults( + for: CodexFetchDescriptor(sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse), CodexSortDescriptor(\.name)]) + ) + try await results.performFetch() + + #expect(results.items.map(\.title) == ["Alpha", "Zulu"]) + } + + @Test("secondary descriptors use exhaustive stable-cursor enumeration before local paging") + func secondaryDescriptorsUseExhaustiveEnumerationBeforeLocalPaging() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, + threads: [.init(id: "thread-zulu", name: "Zulu")], + nextCursor: "server-next" + )) + try await runtime.transport.enqueueUserVisibleThreadList( + .init( + profile: .currentV2, + threads: [.init(id: "thread-alpha", name: "Alpha")] + )) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse), CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let params = try #require(requests.first).decodeParams(ThreadListParams.self) + #expect(requests.count == 3) + #expect(params.limit == nil) + #expect(params.sortKey == "recency_at") + #expect(results.nextCursor == context.localCursor(for: 1)) + #expect(results.items.map(\.title) == ["Alpha"]) + } + + @Test("default chat ordering follows the app-server after a model refresh") + func defaultChatOrderingFollowsAppServerAfterModelRefresh() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha"), + .init(id: "thread-beta", name: "Beta"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor()) + try await results.performFetch() + let alpha = try #require(results.items.first { $0.id.rawValue == "thread-alpha" }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-alpha", name: "Alpha")) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-beta", name: "Beta"), + .init(id: "thread-alpha", name: "Alpha"), + ])) + try await context.refresh(alpha, includeTurns: false) + + #expect(results.items.map(\.id.rawValue) == ["thread-beta", "thread-alpha"]) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("non-recency sort descriptors still apply when recency is present") + func nonRecencySortDescriptorsStillApplyWhenRecencyIsPresent() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-zulu", name: "Zulu"), + .init(id: "thread-alpha", name: "Alpha"), + ])) + + let results = context.fetchedResults( + for: CodexFetchDescriptor(sortBy: [CodexSortDescriptor(\.name), CodexSortDescriptor(\.recencyAt, order: .reverse)]) + ) + try await results.performFetch() + + #expect(results.items.map(\.title) == ["Alpha", "Zulu"]) + } + + @Test("reverse date sorts keep missing dates behind dated chats") + func reverseDateSortsKeepMissingDatesBehindDatedChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-undated", name: "Undated"), + .init( + id: "thread-dated", + name: "Dated", + updatedAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + + let results = context.fetchedResults( + for: CodexFetchDescriptor(sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)]) + ) + try await results.performFetch() + + #expect(results.items.map(\.id.rawValue) == ["thread-dated", "thread-undated"]) + } + + @Test("workspace and chat fetches can be sectioned by relationship aliases") + func fetchesSupportWorkspaceSections() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + let page = try DataKitTestThreadPage(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App chat"), + .init(id: "thread-tools", workspace: tools, name: "Tools chat"), + ]) + try await runtime.transport.enqueueUserVisibleThreadList(page) + + #expect(CodexSectionDescriptor.workspaceGroup == .init(\.workspaceGroupID)) + #expect(CodexSectionDescriptor.workspaceGroup == .init(\.workspaceGroupID)) + #expect(CodexSectionDescriptor.workspace == .init(\.workspaceID)) + + let workspaceResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaces(), + sectionedBy: .workspaceGroup + ) + try await workspaceResults.performFetch() + + let workspaceSection = try #require(workspaceResults.sections.first) + #expect(workspaceResults.items.map(\.name).sorted() == ["App", "Tools"]) + #expect(workspaceResults.sections.count == 1) + #expect(workspaceSection.title == repo.lastPathComponent) + #expect(workspaceSection.items.map(\.name).sorted() == ["App", "Tools"]) + let workspaceGroup = try #require(workspaceSection.workspaceGroup) + #expect(workspaceSection.workspaceGroupID == workspaceGroup.id) + #expect(workspaceSection.workspaces.map(\.id) == workspaceSection.items.map(\.id)) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + + let chatResults = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ), + sectionedBy: .workspace + ) + try await chatResults.performFetch() + + #expect(chatResults.sections.compactMap(\.title).sorted() == ["App", "Tools"]) + #expect( + chatResults.items.map(\.workspace?.workspaceGroup?.id).allSatisfy { + $0 == workspaceResults.items.first?.workspaceGroup?.id + }) + let appSection = try #require(chatResults.sections.first { $0.title == "App" }) + let appWorkspace = try #require(appSection.workspaces.first) + #expect(appSection.workspaceID == appWorkspace.id) + #expect(appSection.workspaceGroup === workspaceGroup) + #expect(appSection.workspaces.map(\.name) == ["App"]) + #expect(appSection.uncategorizedChats.isEmpty) + #expect(appSection.chats(in: appWorkspace.id).map(\.id.rawValue) == ["thread-app"]) + #expect(appSection.chat(id: "thread-app")?.id.rawValue == "thread-app") + #expect(appSection.chat(id: "thread-tools")?.id.rawValue == nil) + } + + @Test("chat section exposes uncategorized chats") + func chatSectionExposesUncategorizedChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init(id: "thread-app", workspace: workspaceURL, name: "App", sourceKind: .cli), + .init(id: "thread-uncategorized", name: "Uncategorized", sourceKind: .cli), + ])) + + let results = context.fetchedResults( + for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.title)] + ), + sectionedBy: CodexSectionDescriptor(\.workspaceID) + ) + try await results.performFetch() + + let section = try #require(results.sections.first { $0.uncategorizedChats.isEmpty == false }) + #expect(section.workspaceGroupID == nil) + #expect(section.workspaceID == nil) + #expect(section.workspaceGroup == nil) + #expect(section.workspaces.isEmpty) + #expect(section.uncategorizedChats.map(\.id.rawValue) == ["thread-uncategorized"]) + #expect(section.chats(in: .init(rawValue: workspaceURL.standardizedFileURL.path)).isEmpty) + #expect(section.chat(id: "thread-uncategorized")?.id.rawValue == "thread-uncategorized") + } + + @Test("workspace fetch pagination is applied after workspace deduplication") + func workspaceFetchPaginationIsAppliedAfterWorkspaceDeduplication() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstWorkspace = temporaryDirectory() + let secondWorkspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-first-a", workspace: firstWorkspace, name: "First A"), + .init(id: "thread-first-b", workspace: firstWorkspace, name: "First B"), + ], + nextCursor: "server-next" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-second", workspace: secondWorkspace, name: "Second") + ] + )) + + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2 + )) + try await results.performFetch() + + #expect(Set(results.items.map(\.url)) == Set([firstWorkspace, secondWorkspace])) + #expect(results.nextCursor == nil) + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(requests.count == 3) + } + + @Test("local paged workspace load reconciles stale loaded items") + func localPagedWorkspaceLoadReconcilesStaleLoadedItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstWorkspace = temporaryDirectory().appendingPathComponent("Alpha", isDirectory: true) + let secondWorkspace = temporaryDirectory().appendingPathComponent("Beta", isDirectory: true) + let thirdWorkspace = temporaryDirectory().appendingPathComponent("Zulu", isDirectory: true) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: firstWorkspace, name: "Alpha"), + .init(id: "thread-zulu", workspace: thirdWorkspace, name: "Zulu"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(results.items.map(\.name) == ["Alpha"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-beta", workspace: secondWorkspace, name: "Beta"), + .init(id: "thread-zulu", workspace: thirdWorkspace, name: "Zulu"), + ])) + try await results.loadNextPage() + + #expect(results.items.map(\.name) == ["Beta", "Zulu"]) + } + + @Test("workspace regrouping removes it from previous group") + func workspaceRegroupingRemovesItFromPreviousGroup() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-regroup", workspace: workspaceURL, name: "Regroup") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await results.performFetch() + let workspace = try #require(results.items.first) + let previousGroup = try #require(workspace.workspaceGroup) + + try FileManager.default.createDirectory( + at: workspaceURL.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-regroup", workspace: workspaceURL, name: "Regroup") + ])) + try await results.performFetch() + let currentGroup = try #require(workspace.workspaceGroup) + + #expect(currentGroup !== previousGroup) + #expect(previousGroup.workspaces.contains { $0 === workspace } == false) + #expect(currentGroup.workspaces.contains { $0 === workspace }) + } + + @Test("group refresh preserves workspace contents when it moves groups") + func groupRefreshPreservesWorkspaceContentsWhenItMovesGroups() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-regroup", workspace: workspaceURL, name: "Regroup") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await results.performFetch() + let previousGroup = try #require(results.items.first) + let workspace = try #require(previousGroup.workspaces.first) + let chat = try #require(workspace.chats.first) + + try FileManager.default.createDirectory( + at: workspaceURL.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-regroup", workspace: workspaceURL, name: "Regroup") + ])) + try await context.refresh(previousGroup) + + let currentGroup = try #require(workspace.workspaceGroup) + #expect(currentGroup !== previousGroup) + #expect(previousGroup.workspaces.isEmpty) + #expect(currentGroup.workspaces.contains { $0 === workspace }) + #expect(workspace.chats.first === chat) + #expect(chat.workspace === workspace) + #expect(results.items.map(\.id) == [currentGroup.id]) + } + + @Test("group refresh prunes stale chats when a workspace moves groups") + func groupRefreshPrunesStaleChatsWhenWorkspaceMovesGroups() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspaceURL, name: "Stale"), + .init(id: "thread-keep", workspace: workspaceURL, name: "Keep"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await results.performFetch() + let previousGroup = try #require(results.items.first) + let workspace = try #require(previousGroup.workspaces.first) + let staleChat = try #require(workspace.chats.first { $0.id.rawValue == "thread-stale" }) + let keepChat = try #require(workspace.chats.first { $0.id.rawValue == "thread-keep" }) + + try FileManager.default.createDirectory( + at: workspaceURL.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-keep", workspace: workspaceURL, name: "Keep") + ])) + try await context.refresh(previousGroup) + + let currentGroup = try #require(workspace.workspaceGroup) + #expect(currentGroup !== previousGroup) + #expect(previousGroup.workspaces.isEmpty) + #expect(currentGroup.workspaces.contains { $0 === workspace }) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-keep"]) + #expect(keepChat.workspace === workspace) + #expect(staleChat.workspace == nil) + #expect(results.items.map(\.id) == [currentGroup.id]) + } + + @Test("paged workspace fetches prune stale workspace chats") + func pagedWorkspaceFetchesPruneStaleWorkspaceChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale"), + .init(id: "thread-remaining", workspace: workspace, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let fetchedWorkspace = try #require(results.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await results.refresh() + + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("paged workspace revalidation backfills when new parent cannot be inserted") + func pagedWorkspaceRevalidationBackfillsWhenNewParentCannotBeInserted() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let backfill = try createDirectory("Backfill", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: app, name: "Move"), + .init(id: "thread-backfill", workspace: backfill, name: "Backfill"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let chat = try #require(results.items.first?.chats.first) + #expect(results.items.map(\.url) == [app]) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: tools, + name: "Move" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-backfill", workspace: backfill, name: "Backfill"), + .init(id: "thread-move", workspace: tools, name: "Move"), + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.map(\.url) == [backfill]) + } + + @Test("paged workspace revalidation refreshes when a new parent precedes visible items") + func pagedWorkspaceRevalidationRefreshesWhenNewParentPrecedesVisibleItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let incoming = try createDirectory("AIncoming", in: repo) + let visible = try createDirectory("BVisible", in: repo) + let moving = try createDirectory("CMove", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-visible", workspace: visible, name: "Visible"), + .init(id: "thread-move", workspace: moving, name: "Move"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let chat = context.model(for: CodexThreadID(rawValue: "thread-move")) + #expect(chat.workspace?.url == moving) + #expect(results.items.map(\.url) == [visible]) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: incoming, + name: "Move" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: incoming, name: "Move"), + .init(id: "thread-visible", workspace: visible, name: "Visible"), + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.map(\.url) == [incoming]) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("paged group revalidation refreshes when a new parent precedes visible items") + func pagedGroupRevalidationRefreshesWhenNewParentPrecedesVisibleItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let incomingRepo = try gitRepository(named: "AIncoming") + let visibleRepo = try gitRepository(named: "BVisible") + let movingRepo = try gitRepository(named: "CMove") + let incoming = try createDirectory("App", in: incomingRepo) + let visible = try createDirectory("App", in: visibleRepo) + let moving = try createDirectory("App", in: movingRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-visible", workspace: visible, name: "Visible"), + .init(id: "thread-move", workspace: moving, name: "Move"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let chat = context.model(for: CodexThreadID(rawValue: "thread-move")) + #expect(chat.workspace?.url == moving) + #expect(results.items.map(\.name) == ["BVisible"]) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: incoming, + name: "Move" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: incoming, name: "Move"), + .init(id: "thread-visible", workspace: visible, name: "Visible"), + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.map(\.name) == ["AIncoming"]) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("local paged group load reconciles stale loaded items") + func localPagedGroupLoadReconcilesStaleLoadedItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let alphaRepo = try gitRepository(named: "Alpha") + let betaRepo = try gitRepository(named: "Beta") + let zuluRepo = try gitRepository(named: "Zulu") + let alpha = try createDirectory("App", in: alphaRepo) + let beta = try createDirectory("App", in: betaRepo) + let zulu = try createDirectory("App", in: zuluRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: alpha, name: "Alpha"), + .init(id: "thread-zulu", workspace: zulu, name: "Zulu"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + #expect(results.items.map(\.name) == ["Alpha"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-beta", workspace: beta, name: "Beta"), + .init(id: "thread-zulu", workspace: zulu, name: "Zulu"), + ])) + try await results.loadNextPage() + + #expect(results.items.map(\.name) == ["Beta", "Zulu"]) + } + + @Test("server paginated chat fetches preserve existing workspace relationships") + func serverPaginatedChatFetchesPreserveExistingWorkspaceRelationships() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspace, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let fetchedWorkspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-new", workspace: workspace, name: "New") + ], + nextCursor: "server-next" + )) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\CodexChat.recencyAt, order: .reverse)] + )) + try await chatResults.performFetch() + + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == ["thread-existing", "thread-new"]) + } + + @Test("server paginated chat appends preserve previously loaded items") + func serverPaginatedChatAppendsPreservePreviouslyLoadedItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-existing", workspace: workspace, name: "Existing") + ], + nextCursor: "server-next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\CodexChat.recencyAt, order: .reverse)] + )) + try await results.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-new", workspace: workspace, name: "New") + ])) + try await results.loadNextPage() + + #expect(results.items.map(\.id.rawValue) == ["thread-existing", "thread-new"]) + } + + @Test("fully loaded paginated chat fetches prune stale workspace relationships") + func fullyLoadedPaginatedChatFetchesPruneStaleWorkspaceRelationships() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspace, name: "Stale") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let fetchedWorkspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-new", workspace: workspace, name: "New") + ], + nextCursor: "server-next" + )) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\CodexChat.recencyAt, order: .reverse)] + )) + try await chatResults.performFetch() + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == ["thread-stale", "thread-new"]) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspace, name: "Remaining") + ])) + try await chatResults.loadNextPage() + + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-new", "thread-remaining"]) + } + + @Test("active sync preserves archived workspace chats") + func activeSyncPreservesArchivedWorkspaceChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: workspace, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let fetchedWorkspace = try #require(archivedResults.items.first?.workspace) + #expect(fetchedWorkspace.chats.map(\.id.rawValue) == ["thread-archived"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-active", workspace: workspace, name: "Active") + ])) + let activeResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await activeResults.performFetch() + + #expect(activeResults.items.map(\.id.rawValue) == ["thread-active"]) + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == [ + "thread-archived", + "thread-active", + ]) + #expect(fetchedWorkspace.chats.contains { + $0.id.rawValue == "thread-archived" + } == true) + } + + @Test("paged chat fetches append loaded workspace relationships") + func pagedChatFetchesAppendLoadedWorkspaceRelationships() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspace = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-before", workspace: workspace, name: "Before"), + .init(id: "thread-middle", workspace: workspace, name: "Middle"), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + let fetchedWorkspace = try #require(allResults.items.first?.workspace) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-middle", workspace: workspace, name: "Middle") + ], + nextCursor: "next" + )) + let cursorResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await cursorResults.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-after", workspace: workspace, name: "After") + ])) + try await cursorResults.loadNextPage() + + #expect(Set(fetchedWorkspace.chats.map(\.id.rawValue)) == [ + "thread-middle", + "thread-after", + ]) + } + + @Test("group refresh rebuilds workspaces from fetched result") + func groupRefreshRebuildsWorkspacesFromFetchedResult() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App"), + .init(id: "thread-tools", workspace: tools, name: "Tools"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await results.performFetch() + let group = try #require(results.items.first) + #expect(Set(group.workspaces.map(\.url)) == Set([app, tools])) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App") + ])) + try await context.refresh(group) + + #expect(group.workspaces.map(\.url) == [app]) + } + + @Test("group refresh removes stale chats from active fetched results") + func groupRefreshRemovesStaleChatsFromActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App"), + .init(id: "thread-tools", workspace: tools, name: "Tools"), + ])) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let group = try #require(chatResults.items.first?.workspace?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App") + ])) + try await context.refresh(group) + + #expect(chatResults.items.map(\.id.rawValue) == ["thread-app"]) + #expect(group.workspaces.map(\.url) == [app]) + } + + @Test("group refresh preserves chats that moved to another group") + func groupRefreshPreservesChatsThatMovedToAnotherGroup() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let appRepo = try gitRepository(named: "AppRepo") + let toolsRepo = try gitRepository(named: "ToolsRepo") + let app = try createDirectory("App", in: appRepo) + let tools = try createDirectory("Tools", in: toolsRepo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: app, name: "Move") + ])) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let chat = try #require(chatResults.items.first) + let group = try #require(chat.workspace?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-move", workspace: tools, name: "Move") + ])) + try await context.refresh(group) + + #expect(chat.workspace?.url == tools) + #expect(chatResults.items.first === chat) + #expect(group.workspaces.isEmpty) + } + + @Test("group refresh does not prune unrelated groups") + func groupRefreshDoesNotPruneUnrelatedGroups() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let app = temporaryDirectory() + let tools = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App"), + .init(id: "thread-tools", workspace: tools, name: "Tools"), + ])) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let appChat = try #require(chatResults.items.first { $0.id.rawValue == "thread-app" }) + let toolsChat = try #require(chatResults.items.first { $0.id.rawValue == "thread-tools" }) + let appGroup = try #require(appChat.workspace?.workspaceGroup) + let toolsWorkspace = try #require(toolsChat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App") + ])) + try await context.refresh(appGroup) + + #expect(toolsWorkspace.chats.first === toolsChat) + #expect(toolsChat.workspace === toolsWorkspace) + #expect(chatResults.items.contains { $0 === toolsChat }) + } + + @Test("group refresh preserves archived-only workspaces") + func groupRefreshPreservesArchivedOnlyWorkspaces() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let archived = try createDirectory("Archived", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: archived, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let group = try #require(archivedResults.items.first?.workspace?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-active", workspace: app, name: "Active") + ])) + try await context.refresh(group) + + #expect(Set(group.workspaces.map(\.url)) == Set([app, archived])) + } + + @Test("active group fetch preserves archived-only workspaces") + func activeGroupFetchPreservesArchivedOnlyWorkspaces() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let archived = try createDirectory("Archived", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: archived, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let group = try #require(archivedResults.items.first?.workspace?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-active", workspace: app, name: "Active") + ])) + let activeResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups) + try await activeResults.performFetch() + + #expect(activeResults.items.first === group) + #expect(Set(group.workspaces.map(\.url)) == Set([app, archived])) + } + + @Test("workspace group fetches prune siblings omitted from the refreshed active list") + func workspaceGroupFetchesPruneSiblingsOmittedFromRefreshedActiveList() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App"), + .init(id: "thread-tools", workspace: tools, name: "Tools"), + ])) + let allGroups = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await allGroups.performFetch() + let group = try #require(allGroups.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App") + ])) + let scopedGroups = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups) + try await scopedGroups.performFetch() + + #expect(scopedGroups.items.first === group) + #expect(Set(group.workspaces.map(\.url)) == Set([app])) + } + + @Test("workspace refresh revalidates scoped fetched results") + func workspaceRefreshRevalidatesScopedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspaceURL, name: "Stale"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspaceURL, name: "Stale"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let scopedResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: workspace + )) + try await scopedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining") + ])) + try await context.refresh(workspace) + + #expect(scopedResults.items.map(\.id.rawValue) == ["thread-remaining"]) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("workspace refresh inserts newly loaded scoped fetched results") + func workspaceRefreshInsertsNewlyLoadedScopedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let scopedResults = context.fetchedResults(for: CodexFetchDescriptor.chats( + in: workspace + )) + try await scopedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing"), + .init(id: "thread-new", workspace: workspaceURL, name: "New"), + ])) + try await context.refresh(workspace) + + #expect(Set(scopedResults.items.map(\.id.rawValue)) == ["thread-existing", "thread-new"]) + #expect(Set(workspace.chats.map(\.id.rawValue)) == ["thread-existing", "thread-new"]) + } + + @Test("workspace refresh applies scoped workspace when snapshots omit cwd") + func workspaceRefreshAppliesScopedWorkspaceWhenSnapshotsOmitCWD() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadListJSON( + """ + { + "data": [ + { + "id": "thread-new", + "name": "New" + } + ] + } + """ + ) + try await context.refresh(workspace) + + let chat = try #require(workspace.chats.first) + #expect(chat.workspace === workspace) + #expect(chat.id.rawValue == "thread-new") + #expect(chat.title == "New") + } + + @Test("workspace refresh revalidates unscoped fetched results") + func workspaceRefreshRevalidatesUnscopedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspaceURL, name: "Stale"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let workspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining") + ])) + try await context.refresh(workspace) + + #expect(results.items.map(\.id.rawValue) == ["thread-remaining"]) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-remaining"]) + } + + @Test("workspace refresh preserves active chats omitted from server page") + func workspaceRefreshPreservesActiveChatsOmittedFromServerPage() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-active-omitted", + workspace: workspaceURL, + name: "Active", + status: .active(activeFlags: []) + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + let workspace = try #require(chat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(workspace) + + #expect(results.items.first === chat) + #expect(workspace.chats.first === chat) + #expect(chat.workspace === workspace) + } + + @Test("group refresh inserts newly loaded workspace fetched results") + func groupRefreshInsertsNewlyLoadedWorkspaceFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let group = try #require(workspaceResults.items.first?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-app", workspace: app, name: "App"), + .init(id: "thread-tools", workspace: tools, name: "Tools"), + ])) + try await context.refresh(group) + + #expect(Set(workspaceResults.items.map(\.url)) == Set([app, tools])) + #expect(Set(group.workspaces.map(\.url)) == Set([app, tools])) + } + + @Test("group refresh preserves active chats omitted from server page") + func groupRefreshPreservesActiveChatsOmittedFromServerPage() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-group-active-omitted", + workspace: workspaceURL, + name: "Active", + status: .active(activeFlags: []) + ) + ])) + let groupResults = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await groupResults.performFetch() + let group = try #require(groupResults.items.first) + let workspace = try #require(group.workspaces.first) + let chat = try #require(workspace.chats.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(group) + + #expect(groupResults.items.first === group) + #expect(group.workspaces.first === workspace) + #expect(workspace.chats.first === chat) + #expect(chat.workspace === workspace) + } + + @Test("workspace refresh preserves archived chats") + func workspaceRefreshPreservesArchivedChats() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: workspaceURL, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let workspace = try #require(archivedResults.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-active", workspace: workspaceURL, name: "Active") + ])) + try await context.refresh(workspace) + + #expect(Set(workspace.chats.map(\.id.rawValue)) == [ + "thread-archived", + "thread-active", + ]) + } + + @Test("workspace refresh revalidates unscoped filtered chat results") + func workspaceRefreshRevalidatesUnscopedFilteredChatResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", workspace: workspaceURL, name: "Match") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: searchChatPredicate("Match") + )) + try await results.performFetch() + let workspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", workspace: workspaceURL, name: "Renamed") + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(workspace) + + #expect(results.items.isEmpty) + #expect(workspace.chats.map(\.title) == ["Renamed"]) + } + + @Test("workspace refresh reloads search-filtered results from server") + func workspaceRefreshReloadsSearchFilteredResultsFromServer() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-search", workspace: workspaceURL, name: "needle") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: searchChatPredicate("needle") + )) + try await results.performFetch() + let chat = try #require(results.items.first) + let workspace = try #require(chat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-search", workspace: workspaceURL, name: "needle") + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-search", workspace: workspaceURL, name: "needle") + ])) + try await context.refresh(workspace) + + #expect(results.items.first === chat) + #expect(workspace.chats.first === chat) + } + + @Test("workspace refresh preserves known chats when a source partition fails") + func workspaceRefreshPreservesKnownChatsWhenSourcePartitionFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-remove", workspace: workspaceURL, name: "Remove") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + let workspace = try #require(chat.workspace) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + await runtime.transport.enqueueFailure(code: -32000, message: "offline", for: "thread/list") + do { + try await context.refresh(workspace) + Issue.record("Expected the second source partition to fail") + } catch { + } + + #expect(results.items.first === chat) + #expect(workspace.chats.first === chat) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 3) + } + + @Test("workspace refresh prunes empty workspace from group") + func workspaceRefreshPrunesEmptyWorkspaceFromGroup() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-stale", workspace: workspaceURL, name: "Stale") + ])) + let groupResults = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await groupResults.performFetch() + let group = try #require(groupResults.items.first) + let workspace = try #require(group.workspaces.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(workspace) + + #expect(workspace.chats.isEmpty) + #expect(group.workspaces.isEmpty) + } + + @Test("workspace refresh backfills paged chat results after removals") + func workspaceRefreshBackfillsPagedChatResultsAfterRemovals() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let backfillURL = temporaryDirectory() + + try await runtime.transport.enqueueBoundedUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete") + ], + nextCursor: "next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + let workspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-backfill", workspace: backfillURL, name: "Backfill") + ])) + try await context.refresh(workspace) + + #expect(results.items.map(\.id.rawValue) == ["thread-backfill"]) + } + + @Test("group refresh backfills paged chat results after removals") + func groupRefreshBackfillsPagedChatResultsAfterRemovals() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let backfillURL = temporaryDirectory() + + try await runtime.transport.enqueueBoundedUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete") + ], + nextCursor: "next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + let group = try #require(results.items.first?.workspace?.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-backfill", workspace: backfillURL, name: "Backfill") + ])) + try await context.refresh(group) + + #expect(results.items.map(\.id.rawValue) == ["thread-backfill"]) + } + + @Test("workspace results keep parents while matching chats remain") + func workspaceResultsKeepParentsWhileMatchingChatsRemain() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-keep", workspace: workspaceURL, name: "Keep"), + .init(id: "thread-match", workspace: workspaceURL, name: "Match"), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + let chat = try #require(allResults.items.first { $0.id.rawValue == "thread-match" }) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-match", workspace: workspaceURL, name: "Match") + ])) + let filteredResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await filteredResults.performFetch() + #expect(filteredResults.items.isEmpty == false) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-match")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-match", + workspace: workspaceURL, + name: "Renamed" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(chat, includeTurns: false) + + #expect(filteredResults.items.isEmpty == false) + } + + @Test("removing the last chat removes the workspace from its group") + func removingLastChatRemovesWorkspaceFromGroup() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete") + ])) + + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + let workspace = try #require(chat.workspace) + let group = try #require(workspace.workspaceGroup) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await chat.delete() + + #expect(workspace.chats.isEmpty) + #expect(group.workspaces.isEmpty) + } + + @Test("deleting a chat removes it from active fetched results") + func deletingChatRemovesItFromActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + let page = try DataKitTestThreadPage(profile: .partialDTO, threads: [ + .init( + id: "thread-delete", + workspace: workspaceURL, + name: "Delete", + sourceKind: .cli, + turns: [ + .init( + id: "turn-delete", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "item-delete", + kind: .enteredReviewMode, + content: .log("Delete") + ) + ] + ) + ] + ) + ]) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let chat = try #require(chatResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(page) + let groupResults = context.fetchedResults(for: CodexFetchDescriptor.workspaceGroups) + try await groupResults.performFetch() + let turn = try #require(chat.turns.first) + let item = try #require(chat.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await chat.delete() + + #expect(chatResults.items.isEmpty) + #expect(chatResults.sections.isEmpty) + #expect(workspaceResults.items.isEmpty) + #expect(groupResults.items.isEmpty) + #expect(chat.modelContext == nil) + #expect(chat.turns.isEmpty) + #expect(chat.items.isEmpty) + #expect(turn.modelContext == nil) + #expect(turn.chat == nil) + #expect(turn.items.isEmpty) + #expect(item.modelContext == nil) + #expect(item.chat == nil) + #expect(item.turn == nil) + + try await runtime.transport.enqueueUserVisibleThreadList(page) + try await chatResults.performFetch() + let replacementChat = try #require(chatResults.items.first) + let replacementTurn = try #require(replacementChat.turns.first) + let replacementItem = try #require(replacementChat.items.first) + #expect(replacementChat !== chat) + #expect(replacementTurn !== turn) + #expect(replacementItem !== item) + } + + @Test("deleting an observed chat cancels its active observation") + func deletingObservedChatCancelsActiveObservation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete-observed", workspace: workspaceURL, name: "Delete") + ])) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let chat = try #require(chatResults.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-delete-observed")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-delete-observed", + workspace: workspaceURL, + name: "Delete" + )) + let observation = try await chat.observe() + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await chat.delete() + + #expect(chat.modelContext == nil) + #expect(await eventually { changes.isFinished }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-delete-observed")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-delete-observed", + workspace: workspaceURL, + name: "Delete Replacement" + )) + let replacement = context.model(for: CodexThreadID(rawValue: "thread-delete-observed")) + let replacementObservation = try await replacement.observe() + defer { + replacementObservation.cancel() + } + + #expect(replacement.modelContext === context) + #expect(replacement.name == "Delete Replacement") + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 2) + withExtendedLifetime(changes) {} + } + + @Test("server-filtered delete removes known chat when refresh fails") + func serverFilteredDeleteRemovesKnownChatWhenRefreshFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-delete", name: "Delete") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + await runtime.transport.enqueueFailure(code: -32000, message: "offline", for: "thread/list") + try await chat.delete() + + #expect(results.items.isEmpty) + #expect(chat.modelContext == nil) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + } + + @Test("server-only parent results keep parents after local child removal") + func serverOnlyParentResultsKeepParentsAfterLocalChildRemoval() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let chatResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await chatResults.performFetch() + let chat = try #require(chatResults.items.first { $0.id.rawValue == "thread-delete" }) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete"), + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining"), + ])) + let groupResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaceGroups) + try await groupResults.performFetch() + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining") + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-remaining", workspace: workspaceURL, name: "Remaining") + ])) + try await chat.delete() + + #expect(workspaceResults.items.first?.url == workspaceURL) + #expect(groupResults.items.first?.workspaces.first?.url == workspaceURL) + } + + @Test("paged fetched results backfill after local removals") + func pagedFetchedResultsBackfillAfterLocalRemovals() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueBoundedUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete") + ], + nextCursor: "next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-backfill", workspace: workspaceURL, name: "Backfill") + ])) + try await chat.delete() + + #expect(results.items.map(\.id.rawValue) == ["thread-backfill"]) + } + + @Test("server-paginated fetched results backfill without explicit limits") + func serverPaginatedFetchedResultsBackfillWithoutExplicitLimits() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-delete", workspace: workspaceURL, name: "Delete") + ], + nextCursor: "next" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\CodexChat.recencyAt, order: .reverse)] + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-backfill", workspace: workspaceURL, name: "Backfill") + ])) + try await chat.delete() + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let backfillParams = try #require(requests.last).decodeParams(ThreadListParams.self) + #expect(backfillParams.cursor == nil) + #expect(backfillParams.limit == nil) + #expect(results.items.map(\.id.rawValue) == ["thread-backfill"]) + } + + @Test("paged fetched results preserve loaded pages while backfilling") + func pagedFetchedResultsPreserveLoadedPagesWhileBackfilling() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let deleteRecency = Date(timeIntervalSince1970: 300) + let keepRecency = Date(timeIntervalSince1970: 200) + let backfillRecency = Date(timeIntervalSince1970: 100) + + try await runtime.transport.enqueueBoundedUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init( + id: "thread-delete", + workspace: workspaceURL, + name: "Delete", + recencyAt: deleteRecency + ) + ], + nextCursor: "page-2" + )) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueBoundedUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init( + id: "thread-delete", + workspace: workspaceURL, + name: "Delete", + recencyAt: deleteRecency + ), + .init( + id: "thread-keep", + workspace: workspaceURL, + name: "Keep", + recencyAt: keepRecency + ) + ], + nextCursor: "page-3" + )) + try await results.loadNextPage() + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-keep", + workspace: workspaceURL, + name: "Keep", + recencyAt: keepRecency + ), + .init( + id: "thread-backfill", + workspace: workspaceURL, + name: "Backfill", + recencyAt: backfillRecency + ), + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-keep", + workspace: workspaceURL, + name: "Keep", + recencyAt: keepRecency + ), + .init( + id: "thread-backfill", + workspace: workspaceURL, + name: "Backfill", + recencyAt: backfillRecency + ), + ])) + try await chat.delete() + + let requests = await runtime.transport.recordedRequests(method: "thread/list") + let refillParams = try requests.suffix(4).map { + try $0.decodeParams(ThreadListParams.self) + } + #expect(requests.count == 8) + #expect(refillParams.map(\.limit) == [1, 1, 2, 2]) + #expect(results.items.map(\.id.rawValue) == ["thread-keep", "thread-backfill"]) + } + + @Test("local paged fetched results recompute backfill cursors after removals") + func localPagedFetchedResultsRecomputeBackfillCursorsAfterRemovals() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", workspace: workspaceURL, name: "A"), + .init(id: "thread-b", workspace: workspaceURL, name: "B"), + .init(id: "thread-c", workspace: workspaceURL, name: "C"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2 + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", workspace: workspaceURL, name: "B"), + .init(id: "thread-c", workspace: workspaceURL, name: "C"), + ])) + try await chat.delete() + + #expect(results.items.map(\.id.rawValue) == ["thread-b", "thread-c"]) + } + + @Test("local paged fetched results preserve starting cursor when loading next page") + func localPagedFetchedResultsPreserveStartingCursorWhenLoadingNextPage() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let threads = [ + DataKitTestThreadFixture(id: "thread-a", workspace: workspaceURL, name: "A"), + DataKitTestThreadFixture(id: "thread-b", workspace: workspaceURL, name: "B"), + DataKitTestThreadFixture(id: "thread-c", workspace: workspaceURL, name: "C"), + DataKitTestThreadFixture(id: "thread-d", workspace: workspaceURL, name: "D"), + ] + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: threads)) + let firstPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2 + )) + try await firstPage.performFetch() + _ = try #require(firstPage.nextCursor) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: threads)) + let offsetPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1, + fetchOffset: 2 + )) + try await offsetPage.performFetch() + #expect(offsetPage.items.map(\.title) == ["C"]) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: threads)) + try await offsetPage.loadNextPage() + + #expect(offsetPage.items.map(\.title) == ["C", "D"]) + #expect(offsetPage.nextCursor == nil) + } + + @Test("local paged fetched results backfill from starting cursor offset after removals") + func localPagedFetchedResultsBackfillFromStartingCursorOffsetAfterRemovals() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let initialThreads = [ + DataKitTestThreadFixture(id: "thread-a", workspace: workspaceURL, name: "A"), + DataKitTestThreadFixture(id: "thread-b", workspace: workspaceURL, name: "B"), + DataKitTestThreadFixture(id: "thread-c", workspace: workspaceURL, name: "C"), + DataKitTestThreadFixture(id: "thread-d", workspace: workspaceURL, name: "D"), + DataKitTestThreadFixture(id: "thread-e", workspace: workspaceURL, name: "E"), + ] + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + let firstPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2 + )) + try await firstPage.performFetch() + _ = try #require(firstPage.nextCursor) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + let offsetPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2, + fetchOffset: 2 + )) + try await offsetPage.performFetch() + let deletedChat = try #require(offsetPage.items.first) + #expect(offsetPage.items.map(\.title) == ["C", "D"]) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", workspace: workspaceURL, name: "A"), + .init(id: "thread-b", workspace: workspaceURL, name: "B"), + .init(id: "thread-d", workspace: workspaceURL, name: "D"), + .init(id: "thread-e", workspace: workspaceURL, name: "E"), + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-a", workspace: workspaceURL, name: "A"), + .init(id: "thread-b", workspace: workspaceURL, name: "B"), + .init(id: "thread-d", workspace: workspaceURL, name: "D"), + .init(id: "thread-e", workspace: workspaceURL, name: "E"), + ])) + try await deletedChat.delete() + + #expect(offsetPage.items.map(\.title) == ["D", "E"]) + #expect(offsetPage.nextCursor == nil) + } + + @Test("cursor-started local pages refetch when earlier chats are removed") + func cursorStartedLocalPagesRefetchWhenEarlierChatsAreRemoved() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let initialThreads = [ + DataKitTestThreadFixture(id: "thread-a", workspace: workspaceURL, name: "A"), + DataKitTestThreadFixture(id: "thread-b", workspace: workspaceURL, name: "B"), + DataKitTestThreadFixture(id: "thread-c", workspace: workspaceURL, name: "C"), + DataKitTestThreadFixture(id: "thread-d", workspace: workspaceURL, name: "D"), + DataKitTestThreadFixture(id: "thread-e", workspace: workspaceURL, name: "E"), + ] + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + var firstPage: CodexFetchedResults? = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2 + )) + try await firstPage?.performFetch() + _ = try #require(firstPage?.nextCursor) + let deletedChat = context.model(for: CodexThreadID(rawValue: "thread-a")) + firstPage = nil + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + let offsetPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2, + fetchOffset: 2 + )) + try await offsetPage.performFetch() + #expect(offsetPage.items.map(\.title) == ["C", "D"]) + + try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", workspace: workspaceURL, name: "B"), + .init(id: "thread-c", workspace: workspaceURL, name: "C"), + .init(id: "thread-d", workspace: workspaceURL, name: "D"), + .init(id: "thread-e", workspace: workspaceURL, name: "E"), + ])) + try await deletedChat.delete() + + #expect(offsetPage.items.map(\.title) == ["D", "E"]) + } + + @Test("cursor-started local pages refetch when visible chats move before the cursor") + func cursorStartedLocalPagesRefetchWhenVisibleChatsMoveBeforeCursor() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let initialThreads = [ + DataKitTestThreadFixture(id: "thread-a", workspace: workspaceURL, name: "A"), + DataKitTestThreadFixture(id: "thread-b", workspace: workspaceURL, name: "B"), + DataKitTestThreadFixture(id: "thread-c", workspace: workspaceURL, name: "C"), + ] + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + let firstPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await firstPage.performFetch() + _ = try #require(firstPage.nextCursor) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: initialThreads)) + let offsetPage = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 2, + fetchOffset: 1 + )) + try await offsetPage.performFetch() + let movingChat = try #require(offsetPage.items.first) + #expect(offsetPage.items.map(\.title) == ["B", "C"]) + #expect(offsetPage.nextCursor == nil) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-b")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-b", + workspace: workspaceURL, + name: "0" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", workspace: workspaceURL, name: "0"), + .init(id: "thread-a", workspace: workspaceURL, name: "A"), + .init(id: "thread-c", workspace: workspaceURL, name: "C"), + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-b", workspace: workspaceURL, name: "0"), + .init(id: "thread-a", workspace: workspaceURL, name: "A"), + .init(id: "thread-c", workspace: workspaceURL, name: "C"), + ])) + try await context.refresh(movingChat, includeTurns: false) + + #expect(offsetPage.items.map(\.title) == ["A", "C"]) + #expect(offsetPage.nextCursor == nil) + } + + @Test("starting a chat inserts it into active fetched results") + func startingChatInsertsItIntoActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let workspace = try #require(results.items.first?.workspace) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + let chat = try await workspace.startChat() + + #expect(results.items.first === chat) + #expect(results.sections.first?.items.first === chat) + #expect(chat.source == .appServer) + #expect(chat.sourceKind == .appServer) + } + + @Test("starting a chat excludes it from fetched results when pending changes are disabled") + func startingChatExcludesItFromFetchedResultsWhenPendingChangesAreDisabled() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let existing = DataKitTestThreadFixture( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [existing])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [existing])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + includeContextChanges: false + )) + try await results.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [existing])) + let chat = try await workspace.startChat() + + #expect(chat.id == "thread-new") + #expect(results.items.map(\.id.rawValue) == ["thread-existing"]) + } + + @Test("starting a chat preserves requested provider for filtered results") + func startingChatPreservesRequestedProviderForFilteredResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let providerResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: modelProviderChatPredicate(["openai"]) + )) + try await providerResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-new", + workspace: workspaceURL, + name: "New", + modelProvider: "openai" + ) + ])) + let chat = try await workspace.startChat(.init( + options: .init(modelProvider: "openai") + )) + + #expect(chat.modelProvider == "openai") + #expect(providerResults.items.first === chat) + } + + @Test("starting a chat refreshes provider-filtered results when provider is unknown") + func startingChatRefreshesProviderFilteredResultsWhenProviderIsUnknown() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let providerResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: modelProviderChatPredicate(["openai"]) + )) + try await providerResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-new", + workspace: workspaceURL, + name: "New", + modelProvider: "openai" + ) + ])) + let chat = try await workspace.startChat() + + #expect(chat.modelProvider == "openai") + #expect(providerResults.items.first === chat) + } + + @Test("starting a chat refreshes server-filtered fetched results") + func startingChatRefreshesServerFilteredFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + let serverResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]) + )) + try await serverResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture( + id: "thread-new", + workspace: workspaceURL, + name: "New" + ).withSourceKind(.appServer) + ])) + let chat = try await workspace.startChat() + + #expect(serverResults.items.first === chat) + } + + @Test("empty provider filters revalidate as all providers") + func emptyProviderFiltersRevalidateAsAllProviders() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-any-provider", name: "Before") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor()) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-any-provider")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-any-provider", + name: "After" + )) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-any-provider", name: "After") + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.first === chat) + #expect(chat.title == "After") + } + + @Test("starting a chat updates limited fetched results without overfilling") + func startingChatUpdatesLimitedFetchedResultsWithoutOverfilling() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let pagedResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 1 + )) + try await pagedResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + _ = try await workspace.startChat() + + #expect(pagedResults.items.count == 1) + #expect(pagedResults.items.first?.id.rawValue == "thread-new") + #expect(pagedResults.nextCursor != nil) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-new", workspace: workspaceURL, name: "New", updatedAt: Date()), + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing"), + ])) + try await pagedResults.loadNextPage() + + #expect(pagedResults.items.map(\.id.rawValue) == ["thread-new", "thread-existing"]) + #expect(pagedResults.nextCursor == nil) + let listRequests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(listRequests.count == 6) + let nextPageParams = try #require(listRequests.last).decodeParams(ThreadListParams.self) + #expect(nextPageParams.cursor == nil) + } + + @Test("starting a chat inserts into underfilled limited fetched results") + func startingChatInsertsIntoUnderfilledLimitedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let limitedResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 2 + )) + try await limitedResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + _ = try await workspace.startChat() + + #expect(limitedResults.items.map(\.id.rawValue) == ["thread-new", "thread-existing"]) + } + + @Test("starting a chat refreshes incomplete paged fetched results") + func startingChatRefreshesIncompletePagedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ], + nextCursor: "next" + )) + let pagedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await pagedResults.performFetch() + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init(id: "thread-new", workspace: workspaceURL, name: "New") + ], + nextCursor: "next" + )) + _ = try await workspace.startChat() + + #expect(pagedResults.items.map(\.id.rawValue) == ["thread-new"]) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("loaded limited pages stay loaded after local revalidation") + func loadedLimitedPagesStayLoadedAfterLocalRevalidation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspaceURL, name: "Alpha"), + .init(id: "thread-beta", workspace: workspaceURL, name: "Beta"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspaceURL, name: "Alpha"), + .init(id: "thread-beta", workspace: workspaceURL, name: "Beta"), + ])) + try await results.loadNextPage() + let beta = try #require(results.items.last) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-beta")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-beta", + workspace: workspaceURL, + name: "Gamma" + )) + try await context.refresh(beta, includeTurns: false) + + #expect(results.items.map(\.title) == ["Alpha", "Gamma"]) + } + + @Test("starting a chat preserves the loaded paged window bound") + func startingChatPreservesLoadedPagedWindowBound() async throws { + let workspaceURL = temporaryDirectory() + let older = Date(timeIntervalSince1970: 1_000) + let newer = Date(timeIntervalSince1970: 2_000) + let newerThread = try makeDataKitStoredThreadFixture( + id: "thread-newer", + workspace: workspaceURL, + name: "Newer", + updatedAt: newer + ) + let olderThread = try makeDataKitStoredThreadFixture( + id: "thread-older", + workspace: workspaceURL, + name: "Older", + updatedAt: older + ) + let plannedStart = try makeDataKitStoredThreadFixture( + id: "thread-started", + workspace: workspaceURL + ) + let store = try CodexAppServerTestThreadStore( + threads: [newerThread, olderThread], + plannedStarts: [plannedStart] + ) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: store) + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceResults = context.fetchedResults(for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + let pagedResults = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)], + fetchLimit: 1 + )) + try await pagedResults.performFetch() + try await pagedResults.loadNextPage() + let listRequestCount = await runtime.transport.recordedRequests(method: "thread/list").count + + let started = try await workspace.startChat() + + #expect(pagedResults.items.map(\.id.rawValue) == [ + started.id.rawValue, + "thread-newer", + ]) + #expect(pagedResults.nextCursor != nil) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == listRequestCount) + } + + @Test("archiving a chat moves it between active fetched results") + func archivingChatMovesItBetweenActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let unarchivedResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await unarchivedResults.performFetch() + let chat = try #require(unarchivedResults.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await chat.archive() + + #expect(unarchivedResults.items.isEmpty) + #expect(archivedResults.items.first === chat) + } + + @Test("empty chat predicates keep local sort results active after archive") + func emptyChatPredicatesKeepLocalSortResultsActiveAfterArchive() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)] + )) + try await results.performFetch() + let chat = try #require(results.items.first) + let requestCount = await runtime.transport.recordedRequests(method: "thread/list").count + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await chat.archive() + + #expect(results.items.isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == requestCount) + } + + @Test("implicit active scope applies to locally matched filtered results") + func implicitActiveScopeAppliesToLocallyMatchedFilteredResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archive", workspace: workspaceURL, name: "Archive") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: workspaceChatPredicate(workspaceURL), + sortBy: [CodexSortDescriptor(\.name)] + )) + try await results.performFetch() + let chat = try #require(results.items.first) + let requestCount = await runtime.transport.recordedRequests(method: "thread/list").count + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await chat.archive() + + #expect(results.items.isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == requestCount) + } + + @Test("server-filtered archive removes active chat when refresh fails") + func serverFilteredArchiveRemovesActiveChatWhenRefreshFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-archive", workspace: workspaceURL, name: "Archive") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedSourceKindChatPredicate(archived: false, sourceKinds: [.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + await runtime.transport.enqueueFailure(code: -32000, message: "offline", for: "thread/list") + try await chat.archive() + + #expect(results.items.isEmpty) + #expect(chat.isArchived) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + } + + @Test("unarchiving a chat moves it between active fetched results") + func unarchivingChatMovesItBetweenActiveFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-unarchive", workspace: workspaceURL, name: "Archived") + .withSourceKind(.appServer) + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let chat = try #require(archivedResults.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let unarchivedResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await unarchivedResults.performFetch() + + try await runtime.transport.enqueueThreadUnarchive(.init( + id: "thread-unarchive", + workspace: workspaceURL, + name: "Restored" + )) + try await chat.unarchive() + + #expect(chat.isArchived == false) + #expect(chat.title == "Restored") + #expect(archivedResults.items.isEmpty) + #expect(unarchivedResults.items.first === chat) + #expect(chat.workspace?.chats.first === chat) + } + + @Test("server-filtered unarchive removes archived chat when refresh fails") + func serverFilteredUnarchiveRemovesArchivedChatWhenRefreshFails() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-unarchive", workspace: workspaceURL, name: "Archived") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedSourceKindChatPredicate(archived: true, sourceKinds: [.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadUnarchive(.init( + id: "thread-unarchive", + workspace: workspaceURL, + name: "Restored" + )) + await runtime.transport.enqueueFailure(code: -32000, message: "offline", for: "thread/list") + try await chat.unarchive() + + #expect(results.items.isEmpty) + #expect(chat.isArchived == false) + #expect(chat.title == "Restored") + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 2) + } + + @Test("archiving a chat inserts it into archived fetched results") + func archivingChatInsertsItIntoArchivedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let workspaceURL = try createDirectory("App", in: repo) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-archive", workspace: workspaceURL, name: "Archive") + .withSourceKind(.appServer) + ])) + let unarchivedResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await unarchivedResults.performFetch() + let chat = try #require(unarchivedResults.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await chat.archive() + + #expect(archivedResults.items.first === chat) + #expect(chat.workspace?.url == workspaceURL) + } + + @Test("archived refresh prunes removed archived relationships") + func archivedRefreshPrunesRemovedArchivedRelationships() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-archived", workspace: workspaceURL, name: "Archived") + ])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedChatPredicate(true), + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] + )) + try await archivedResults.performFetch() + let chat = try #require(archivedResults.items.first) + let workspace = try #require(chat.workspace) + let group = try #require(workspace.workspaceGroup) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [])) + try await archivedResults.performFetch() + + #expect(archivedResults.items.isEmpty) + #expect(workspace.chats.isEmpty) + #expect(group.workspaces.contains { $0 === workspace } == false) + } + + @Test("archiving a chat refreshes server-filtered archived results") + func archivingChatRefreshesServerFilteredArchivedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + let archivedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: archivedSourceKindChatPredicate(archived: true, sourceKinds: [.appServer]) + )) + try await archivedResults.performFetch() + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-archive", workspace: workspaceURL, name: "Archive") + .withSourceKind(.appServer) + ])) + let activeResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await activeResults.performFetch() + let chat = try #require(activeResults.items.first) + + try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-archive", workspace: workspaceURL, name: "Archive") + .withSourceKind(.appServer) + ])) + try await chat.archive() + + #expect(activeResults.items.isEmpty) + #expect(archivedResults.items.first === chat) + } + + @Test("metadata-only chat refresh preserves existing turn objects") + func metadataOnlyRefreshPreservesTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let updatedAt = Date(timeIntervalSince1970: 1_000) + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .partialDTO, threads: [ + .init( + id: "thread-refresh", + workspace: workspaceURL, + name: "Before", + modelProvider: "openai", + sourceKind: .cli, + updatedAt: updatedAt, + turns: [.init(id: "turn-refresh", state: .inProgress)] + ) + ])) + + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + let turn = try #require(chat.turns.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh")) + try await runtime.transport.enqueue( + AppServerAPI.Thread.Read.Response(thread: DataKitTestThreadFixture( + id: "thread-refresh", + name: "After", + turns: [] + ).dto(profile: .partialDTO)), + for: "thread/read" + ) + + try await context.refresh(chat, includeTurns: false) + + #expect(chat.title == "After") + #expect(chat.workspace?.url == workspaceURL) + #expect(chat.modelProvider == "openai") + #expect(chat.updatedAt == updatedAt) + #expect(chat.turns.first === turn) + #expect(turn.status == CodexTurnStatus.inProgress) + + let request = try #require( + await runtime.transport.recordedRequests(method: "thread/read").first) + let params = try request.decodeParams(ThreadReadParams.self) + #expect(params.threadID == "thread-refresh") + #expect(params.includeTurns == false) + } + + @Test("metadata-only thread status never synthesizes a terminal turn state") + func metadataOnlyThreadStatusNeverSynthesizesTerminalTurnState() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-metadata-phase")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-metadata-phase", + status: .active(activeFlags: []), + turns: [.init(id: "turn-stale", state: .inProgress)] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-metadata-phase")) + try await context.refresh(chat) + #expect(chat.phase == .running(turnID: "turn-stale")) + + for status: CodexThreadStatus in [.idle, .notLoaded, .systemError] { + try await runtime.transport.enqueueThreadResume(.init(id: "thread-metadata-phase")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-metadata-phase", + status: status + )) + + try await context.refresh(chat, includeTurns: false) + + #expect(chat.turn(id: "turn-stale")?.state == .inProgress) + #expect(chat.phase == .idle) + #expect(chat.status == status) + } + } + + @Test("turn snapshots without fresh thread status preserve app-server thread status") + func turnSnapshotsWithoutFreshThreadStatusPreserveAppServerThreadStatus() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-stale-status")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-stale-status", + status: .idle + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-stale-status")) + try await context.refresh(chat, includeTurns: false) + #expect(chat.status == .idle) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-stale-status")) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-running-after-idle", + state: .inProgress, + items: [ + .init( + id: "command-running-after-idle", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc", + status: .inProgress, + startedAt: Date(timeIntervalSince1970: 4_000) + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-stale-status")) + + try await context.refresh(chat) + + let turn = try #require(chat.turn(id: "turn-running-after-idle")) + let commandItem = try #require(chat.items.first { $0.itemID == "command-running-after-idle" }) + guard case .command(let command) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(turn.status == .inProgress) + #expect(command.status == .inProgress) + #expect(command.completedAt == nil) + #expect(chat.status == .idle) + #expect(chat.phase == .running(turnID: "turn-running-after-idle")) + } + + @Test("chat refresh cancellation restores its stable typed phase") + func chatRefreshCancellationRestoresStablePhase() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let readGate = CodexAppServerTestGate() + let chat = context.model(for: CodexThreadID(rawValue: "thread-cancel-refresh")) + + try await runtime.transport.enqueueThreadResume(.init(id: chat.id)) + try await runtime.transport.enqueueThreadRead(.init(id: chat.id, status: .idle)) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/read", + gate: readGate + ) + let refresh = Task { @MainActor in + try await context.refresh(chat, includeTurns: false) + } + await runtime.transport.waitForRequest(method: "thread/read") + + refresh.cancel() + await readGate.open() + do { + try await refresh.value + Issue.record("Expected chat refresh cancellation") + } catch is CancellationError { + } + + #expect(chat.phase == .idle) + } + + @Test("cancelled chat operations do not overwrite a newer live terminal phase") + func cancelledChatOperationPreservesNewerTerminalPhase() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-cancel-live-terminal")) + + chat.beginLoading() + _ = chat.apply(.completed(CodexResponse(turnID: "turn-live-terminal"))) + chat.restorePhaseIfLoading(.idle) + + #expect(chat.phase == .terminal( + turnID: "turn-live-terminal", + disposition: .completed + )) + } + + @Test("chat observation setup cancellation releases its slot and restores phase") + func chatObservationSetupCancellationReleasesSlot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let readGate = CodexAppServerTestGate() + let chat = context.model(for: CodexThreadID(rawValue: "thread-cancel-observe")) + + try await runtime.transport.enqueueThreadResume(.init(id: chat.id)) + try await runtime.transport.enqueueThreadRead(.init(id: chat.id, status: .idle)) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/read", + gate: readGate + ) + let setup = Task { @MainActor in + do { + _ = try await chat.observe(includeTurns: false) + Issue.record("Expected chat observation setup cancellation") + } catch is CancellationError { + } catch { + Issue.record("Unexpected chat observation setup error: \(error)") + } + } + await runtime.transport.waitForRequest(method: "thread/read") + + setup.cancel() + await readGate.open() + await setup.value + #expect(chat.phase == .idle) + + try await runtime.transport.enqueueThreadResume(.init(id: chat.id)) + try await runtime.transport.enqueueThreadRead(.init(id: chat.id, status: .idle)) + let observation = try await chat.observe(includeTurns: false) + observation.cancel() + } + + @Test("fresh idle thread status does not rewrite a running turn snapshot") + func freshIdleThreadStatusDoesNotRewriteRunningTurnSnapshot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-idle-with-running-turn")) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-stale-running", + state: .inProgress, + items: [ + .init( + id: "command-stale-running", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc", + status: .inProgress, + startedAt: Date(timeIntervalSince1970: 4_500) + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-idle-with-running-turn", + status: .idle + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-idle-with-running-turn")) + try await context.refresh(chat) + + #expect(chat.status == .idle) + #expect(chat.phase == .running(turnID: "turn-stale-running")) + #expect(chat.turn(id: "turn-stale-running")?.state == .inProgress) + } + + @Test("server-only chat refresh re-sorts current results") + func serverOnlyChatRefreshResortsCurrentResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-beta", name: "Beta") + .withSourceKind(.appServer), + DataKitTestThreadFixture(id: "thread-alpha", name: "Alpha") + .withSourceKind(.appServer), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindChatPredicate([.appServer]), + sortBy: [CodexSortDescriptor(\.name)] + )) + try await results.performFetch() + let beta = try #require(results.items.first { $0.id.rawValue == "thread-beta" }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-beta")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-beta", name: "Aardvark")) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-beta", name: "Aardvark") + .withSourceKind(.appServer), + DataKitTestThreadFixture(id: "thread-alpha", name: "Alpha") + .withSourceKind(.appServer), + ])) + try await context.refresh(beta, includeTurns: false) + + #expect(results.items.map(\.title) == ["Aardvark", "Alpha"]) + } + + @Test("server-only chat refresh applies local workspace filters") + func serverOnlyChatRefreshAppliesLocalWorkspaceFilters() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try gitRepository() + let app = try createDirectory("App", in: repo) + let tools = try createDirectory("Tools", in: repo) + + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [ + DataKitTestThreadFixture(id: "thread-move", workspace: app, name: "Move") + .withSourceKind(.appServer) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: workspaceSourceKindChatPredicate(workspace: app, sourceKinds: [.appServer]) + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-move")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-move", + workspace: tools, + name: "Move" + )) + try await runtime.transport.enqueueThreadList(.init(profile: .currentV2, threads: [])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.isEmpty) + } + + @Test("search-filtered chat refresh reloads server membership") + func searchFilteredChatRefreshReloadsServerMembership() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-search", name: "needle") + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + predicate: searchChatPredicate("needle") + )) + try await results.performFetch() + let chat = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-search")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-search", name: "needle")) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-search", name: "needle") + ])) + try await context.refresh(chat, includeTurns: false) + + #expect(results.items.first === chat) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("thread list fetch coalesces locally filtered revalidations") + func threadListFetchCoalescesLocallyFilteredRevalidations() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-first", name: "First", preview: "needle"), + .init(id: "thread-second", name: "Second", preview: "needle"), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-first", name: "First", preview: "needle"), + .init(id: "thread-second", name: "Second", preview: "needle"), + ])) + let searchResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: searchChatPredicate("needle") + )) + try await searchResults.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-first", name: "First renamed", preview: "needle"), + .init(id: "thread-second", name: "Second renamed", preview: "needle"), + ])) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-first", name: "First renamed", preview: "needle"), + .init(id: "thread-second", name: "Second renamed", preview: "needle"), + ])) + try await allResults.performFetch() + + #expect(searchResults.items.map(\.title) == ["Second renamed", "First renamed"]) + let recordedRequests = await runtime.transport.recordedRequests(method: "thread/list") + #expect(recordedRequests.count == 8) + let refreshParams = try #require(recordedRequests.last).decodeParams(ThreadListParams.self) + #expect(refreshParams.searchTerm == nil) + } + + @Test("paged chat refresh reloads incomplete results after sort key changes") + func pagedChatRefreshReloadsIncompleteResultsAfterSortKeyChanges() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha"), + .init(id: "thread-beta", name: "Beta"), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.name)], + fetchLimit: 1 + )) + try await results.performFetch() + let alpha = try #require(results.items.first) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-alpha", name: "Zulu")) + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Zulu"), + .init(id: "thread-beta", name: "Beta"), + ])) + try await context.refresh(alpha, includeTurns: false) + + #expect(results.items.map(\.id.rawValue) == ["thread-beta"]) + } + + @Test("thread list empty turn arrays preserve cached turns and items") + func threadListEmptyTurnArraysPreserveCachedTurnsAndItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-clear", + name: "Before", + sourceKind: .cli, + turns: [ + .init( + id: "turn-clear", + state: .completed, + items: [ + .init( + id: "message-clear", + kind: .agentMessage, + content: .message(.init( + id: "message-clear", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ) + ] + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + #expect(chat.turns.isEmpty == false) + #expect(chat.items.isEmpty == false) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init(id: "thread-clear", name: "After", sourceKind: .cli, turns: []) + ])) + try await results.refresh() + + #expect(chat.title == "After") + #expect(chat.turns.isEmpty == false) + #expect(chat.items.isEmpty == false) + } + + @Test("thread list summary turns preserve cached transcript items") + func threadListSummaryTurnsPreserveCachedTranscriptItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-summary", + name: "Before", + sourceKind: .cli, + turns: [ + .init( + id: "turn-summary", + state: .inProgress, + items: [ + .init( + id: "message-summary", + kind: .agentMessage, + content: .message(.init( + id: "message-summary", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ), + .init( + id: "turn-omitted", + state: .inProgress + ) + ] + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + let turn = try #require(chat.turns.first) + let item = try #require(chat.items.first) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-summary", + name: "After", + sourceKind: .cli, + turns: [ + .init( + id: "turn-summary", + state: .completed, + itemsLoadState: .summary, + items: [ + .init( + id: "message-summary", + kind: .agentMessage, + content: .message(.init( + id: "message-summary", + role: .assistant, + phase: .finalAnswer, + text: "Summary placeholder" + )) + ), + ] + ) + ] + ) + ])) + try await results.refresh() + + #expect(chat.title == "After") + #expect(chat.turns.first === turn) + #expect(turn.status == CodexTurnStatus.completed) + #expect(chat.turns.contains { $0.id == "turn-omitted" }) + #expect(chat.items.first === item) + #expect(item.text == "Done") + #expect(chat.transcript.finalAnswer == "Done") + } + + @Test("explicit empty read turn lists clear cached turns and items") + func explicitEmptyReadTurnListsClearCachedTurnsAndItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-clear", + name: "Before", + sourceKind: .cli, + turns: [ + .init( + id: "turn-clear", + state: .completed, + items: [ + .init( + id: "message-clear", + kind: .agentMessage, + content: .message(.init( + id: "message-clear", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ) + ] + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + #expect(chat.turns.isEmpty == false) + #expect(chat.items.isEmpty == false) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-clear")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-clear", + name: "After", + turns: [] + )) + try await context.refresh(chat) + + #expect(chat.turns.isEmpty) + #expect(chat.items.isEmpty) + } + + @Test("included reads without turns clear cached turns and items") + func includedReadsWithoutTurnsClearCachedTurnsAndItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .partialDTO, threads: [ + .init( + id: "thread-omitted-read", + name: "Before", + sourceKind: .cli, + turns: [ + .init( + id: "turn-omitted-read", + state: .completed, + items: [ + .init( + id: "message-omitted-read", + kind: .agentMessage, + content: .message(.init( + id: "message-omitted-read", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ) + ] + ) + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let chat = try #require(results.items.first) + #expect(chat.turns.isEmpty == false) + #expect(chat.items.isEmpty == false) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-omitted-read")) + try await runtime.transport.enqueueJSON( + """ + { + "thread": { + "id": "thread-omitted-read", + "name": "After" + } + } + """, + for: "thread/read" + ) + try await context.refresh(chat) + + #expect(chat.title == "After") + #expect(chat.turns.isEmpty) + #expect(chat.items.isEmpty) + } + + @Test("chat refresh populates transcript items from turn history") + func chatRefreshPopulatesTranscriptItemsFromTurnHistory() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-history")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-history", + turns: [ + .init( + id: "turn-history", + state: .completed, + items: [ + .init( + id: "message-history", + kind: .agentMessage, + content: .message(.init( + id: "message-history", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-history")) + try await context.refresh(chat) + + let item = try #require(chat.items.first) + #expect(chat.items.count == 1) + #expect(item.itemID == "message-history") + #expect(item.turnID == "turn-history") + #expect(item.text == "Done") + #expect(chat.transcript.finalAnswer == "Done") + } + + @Test("chat refresh loads full turn items through thread turns list") + func chatRefreshLoadsFullTurnItemsThroughThreadTurnsList() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-turns-list")) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-live", + state: .inProgress, + items: [ + .init( + id: "message-live", + kind: .agentMessage, + content: .message(.init( + id: "message-live", + role: .assistant, + text: "Active turn snapshot" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-turns-list", + name: "Turns list" + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-turns-list")) + try await context.refresh(chat) + + #expect(chat.title == "Turns list") + #expect(chat.items.map(\.text) == ["Active turn snapshot"]) + #expect(await runtime.transport.recordedRequests(method: "thread/turns/list").count == 1) + let readRequest = try #require(await runtime.transport.recordedRequests(method: "thread/read").first) + let readParams = try readRequest.decodeParams(ThreadReadParams.self) + #expect(readParams.includeTurns == false) + } + + @Test("chat refresh follows all turn-list pages before applying authoritative turns") + func chatRefreshFollowsAllTurnListPagesBeforeApplyingAuthoritativeTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-turns-pages")) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, + turns: [ + .init( + id: "turn-page-1", + state: .completed, + items: [ + .init( + id: "message-page-1", + kind: .agentMessage, + content: .message(.init( + id: "message-page-1", + role: .assistant, + text: "First page" + )) + ), + ] + ), + ], + nextCursor: "page-2" + )) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-page-2", + state: .completed, + items: [ + .init( + id: "message-page-2", + kind: .agentMessage, + content: .message(.init( + id: "message-page-2", + role: .assistant, + text: "Second page" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-turns-pages", + name: "Turns pages" + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-turns-pages")) + try await context.refresh(chat) + + #expect(chat.items.map(\.text) == ["First page", "Second page"]) + #expect(chat.turns.map(\.id.rawValue) == ["turn-page-1", "turn-page-2"]) + let requests = await runtime.transport.recordedRequests(method: "thread/turns/list") + #expect(requests.count == 2) + let firstParams = try #require(requests.first).decodeParams(ThreadTurnsListParams.self) + let secondParams = try #require(requests.dropFirst().first).decodeParams(ThreadTurnsListParams.self) + #expect(firstParams.cursor == nil) + #expect(secondParams.cursor == "page-2") + } + + @Test("chat turn helpers scope items and preserve identity") + func chatTurnHelpersScopeItemsAndPreserveIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-snapshot")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-snapshot", + turns: [ + .init( + id: "turn-alpha", + state: .completed, + items: [ + .init( + id: "message-alpha-user", + kind: .userMessage, + content: .message(.init( + id: "message-alpha-user", + role: .user, + text: "Question" + )) + ), + .init( + id: "message-alpha-agent", + kind: .agentMessage, + content: .message(.init( + id: "message-alpha-agent", + role: .assistant, + phase: .finalAnswer, + text: "Alpha answer" + )) + ), + ] + ), + .init( + id: "turn-beta", + state: .inProgress, + items: [ + .init( + id: "message-beta", + kind: .agentMessage, + content: .message(.init( + id: "message-beta", + role: .assistant, + text: "Beta update" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-snapshot")) + try await context.refresh(chat) + + let alphaTurn = try #require(chat.turn(id: "turn-alpha")) + let alphaItem = try #require(chat.items.first { $0.itemID == "message-alpha-user" }) + let alphaItems = chat.items(in: "turn-alpha") + let betaItems = chat.items(in: "turn-beta") + let alphaThreadItems = threadItems(from: alphaItems) + + #expect(alphaItems.map(\.itemID) == ["message-alpha-user", "message-alpha-agent"]) + #expect(betaItems.map(\.itemID) == ["message-beta"]) + #expect(alphaItems.first === alphaItem) + #expect(alphaTurn.status == CodexTurnStatus.completed) + #expect(alphaTurn.error == nil) + #expect(alphaTurn.usage == nil) + #expect(alphaThreadItems.map(\.id) == ["message-alpha-user", "message-alpha-agent"]) + #expect(CodexTranscript(items: alphaThreadItems).finalAnswer == "Alpha answer") + } + + @Test("chat turn helpers expose metadata and missing turn results") + func chatTurnHelpersExposeMetadataAndMissingTurnResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-turn-metadata")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-turn-metadata", + turns: [ + .init( + id: "turn-completed", + state: .completed, + items: [ + .init( + id: "message-completed", + kind: .agentMessage, + content: .message(.init( + id: "message-completed", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ), + .init( + id: "turn-failed", + state: .failed(.init( + message: "Tool failed", + info: .httpConnectionFailed(httpStatusCode: 503), + additionalDetails: "upstream detail" + )), + items: [ + .init( + id: "message-failed", + kind: .agentMessage, + content: .message(.init( + id: "message-failed", + role: .assistant, + text: "Failed" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-turn-metadata")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + let failedTurn = try #require(chat.turn(id: "turn-failed")) + #expect(failedTurn.status == CodexTurnStatus.failed) + #expect(failedTurn.error == .init( + message: "Tool failed", + info: .httpConnectionFailed(httpStatusCode: 503), + additionalDetails: "upstream detail" + )) + #expect(failedTurn.usage == nil) + #expect(chat.turn(id: "turn-missing") == nil) + #expect(chat.items(in: "turn-missing").isEmpty) + + try await runtime.transport.emitServerNotification( + method: "thread/tokenUsage/updated", + params: TokenUsageParams( + threadID: "thread-turn-metadata", + turnID: "turn-completed", + tokenUsage: .init( + total: .init(inputTokens: 13, outputTokens: 21, totalTokens: 34), + modelContextWindow: 128_000 + ) + ) + ) + + #expect(await eventually { + chat.turn(id: "turn-completed")?.usage?.totalTokens == 34 + }) + let completedTurn = try #require(chat.turn(id: "turn-completed")) + #expect(completedTurn.usage?.inputTokens == 13) + #expect(completedTurn.usage?.outputTokens == 21) + #expect(completedTurn.usage?.modelContextWindow == 128_000) + withExtendedLifetime(changes) {} + } + + @Test("chat send merges response transcript into observable items") + func chatSendMergesTranscriptItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-send")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-send", status: "running") + + let chat = context.model(for: CodexThreadID(rawValue: "thread-send")) + let sendTask = Task { + try await chat.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-send", + turnID: "turn-send", + item: .init( + id: "message-1", + type: "agentMessage", + text: "Done", + phase: "final_answer" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-send", + turn: .init(id: "turn-send", status: "completed") + ) + ) + + let response = try await sendTask.value + let item = try #require(chat.items.first) + #expect(response.response.turnID == "turn-send") + #expect(chat.turns.first?.status == CodexTurnStatus.completed) + #expect(item.text == "Done") + #expect(item.turnID == "turn-send") + #expect(chat.transcript.finalAnswer == "Done") + } + + @Test("chat send cancellation applies the interrupted terminal outcome") + func chatSendCancellationAppliesInterruptedTerminalOutcome() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-cancelled-send")) + try await runtime.transport.enqueueTurnStart( + turnID: "turn-cancelled-send", + status: "running" + ) + try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + + let chat = context.model(for: CodexThreadID(rawValue: "thread-cancelled-send")) + let sendTask = Task { + try await chat.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + sendTask.cancel() + await runtime.transport.waitForRequest(method: "turn/interrupt") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-cancelled-send", + turn: .init(id: "turn-cancelled-send", status: "interrupted") + ) + ) + + do { + _ = try await sendTask.value + Issue.record("Expected the cancelled send to throw CancellationError.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error).") + } + + #expect(chat.turn(id: "turn-cancelled-send")?.status == .interrupted) + #expect(chat.phase == .terminal( + turnID: "turn-cancelled-send", + disposition: .interrupted + )) + await runtime.close() + } + + @Test("observed chat send emits a loaded phase change") + func observedChatSendEmitsLoadedPhaseChange() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-send-phase")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-send-phase", + status: .active(activeFlags: []), + turns: [.init(id: "turn-existing", state: .inProgress)] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-send-phase")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let updateRecorder = ChatUpdateRecorder(stream: observation.updates) + #expect(chat.phase == .running(turnID: "turn-existing")) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-send-phase")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-send-phase", status: "running") + let sendTask = Task { + try await chat.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-send-phase", + turn: .init(id: "turn-send-phase", status: "completed") + ) + ) + + _ = try await sendTask.value + + let phaseChange = await updateRecorder.phaseChanged(.terminal( + turnID: "turn-send-phase", + disposition: .completed + )) + #expect(phaseChange != nil) + #expect(chat.phase == .terminal( + turnID: "turn-send-phase", + disposition: .completed + )) + } + + @Test("thread event lifecycle updates observable chat status") + func threadEventLifecycleUpdatesObservableChatStatus() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-status-lifecycle")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-status-lifecycle", + status: .idle, + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-status-lifecycle")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let updateRecorder = ChatUpdateRecorder(stream: observation.updates) + + #expect(chat.status == .idle) + #expect(chat.phase == .idle) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-status-lifecycle", + turnID: "turn-status-lifecycle" + ) + ) + + #expect(await updateRecorder.statusChanged(.active(activeFlags: [])) != nil) + #expect(chat.status == .active(activeFlags: [])) + #expect(chat.phase == .running(turnID: "turn-status-lifecycle")) + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-status-lifecycle", + turn: .init(id: "turn-status-lifecycle", status: "completed") + ) + ) + + #expect(await updateRecorder.statusChanged(.idle) != nil) + #expect(chat.status == .idle) + #expect(chat.phase == .terminal( + turnID: "turn-status-lifecycle", + disposition: .completed + )) + } + + @Test("item lifecycle updates observable command status") + func itemLifecycleUpdatesObservableCommandStatus() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-lifecycle")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-command-lifecycle", + status: .idle, + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-lifecycle")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-lifecycle", + turnID: "turn-command-lifecycle" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-lifecycle", + turnID: "turn-command-lifecycle", + startedAtMs: 1_782_900_000_000, + item: .init( + id: "command-1", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + + #expect(await changes.itemInserted(id: "command-1") != nil) + let commandItem = try #require(chat.items.first { $0.itemID == "command-1" }) + guard case .command(let startedCommand) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(startedCommand.status == .inProgress) + #expect(startedCommand.startedAt != nil) + + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-command-lifecycle", + turnID: "turn-command-lifecycle", + itemID: "command-1", + delta: "done" + ) + ) + #expect(await changes.itemUpdated(id: "command-1") != nil) + guard case .command(let updatedCommand) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(updatedCommand.status == .inProgress) + + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-command-lifecycle", + turnID: "turn-command-lifecycle", + completedAtMs: 1_782_900_001_000, + item: .init( + id: "command-1", + type: "commandExecution", + command: "/bin/zsh -lc", + output: "done", + exitCode: 0, + status: "running" + ) + ) + ) + #expect(await changes.itemUpdated(id: "command-1") != nil) + guard case .command(let completedCommand) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(completedCommand.status == .completed) + #expect(completedCommand.completedAt != nil) + } + + @Test("thread inactive status never synthesizes turn or item completion") + func threadInactiveStatusNeverSynthesizesTurnOrItemCompletion() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-status-terminal")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-command-status-terminal", + status: .idle, + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-status-terminal")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-status-terminal", + turnID: "turn-command-status-terminal" + ) + ) + let startedAt = Date().addingTimeInterval(-45) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-status-terminal", + turnID: "turn-command-status-terminal", + startedAtMs: Int64((startedAt.timeIntervalSince1970 * 1_000).rounded()), + item: .init( + id: "command-status-terminal", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + + #expect(await changes.itemInserted(id: "command-status-terminal") != nil) + let commandItem = try #require(chat.items.first { $0.itemID == "command-status-terminal" }) + + try await runtime.transport.emitServerNotification( + method: "thread/status/changed", + params: ThreadStatusParams( + threadID: "thread-command-status-terminal", + status: .init(type: "idle") + ) + ) + + #expect(await eventually { + chat.status == .idle + && chat.phase == .running(turnID: "turn-command-status-terminal") + }) + guard case .command(let command) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(chat.turn(id: "turn-command-status-terminal")?.state == .inProgress) + #expect(command.status == .inProgress) + #expect(command.startedAt != nil) + #expect(command.completedAt == nil) + #expect(chat.phase == .running(turnID: "turn-command-status-terminal")) + #expect(chat.status == .idle) + withExtendedLifetime(changes) {} + } + + @Test("turn completion does not become a command completion timestamp") + func turnCompletionDoesNotBecomeCommandCompletionTimestamp() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-turn-completion")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-command-turn-completion", + status: .idle, + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-turn-completion")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-turn-completion", + turnID: "turn-command-turn-completion" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-turn-completion", + turnID: "turn-command-turn-completion", + startedAtMs: 1_782_900_000_000, + item: .init( + id: "command-turn-completion", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + #expect(await changes.itemInserted(id: "command-turn-completion") != nil) + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-command-turn-completion", + turn: .init( + id: "turn-command-turn-completion", + status: "completed", + completedAt: 1_782_900_100 + ) + ) + ) + + #expect(await eventually { + chat.turn(id: "turn-command-turn-completion")?.state == .completed + }) + let commandItem = try #require(chat.items.first { + $0.itemID == "command-turn-completion" + }) + guard case .command(let command) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(command.status == .completed) + #expect(command.startedAt != nil) + #expect(command.completedAt == nil) + #expect(command.duration == nil) + withExtendedLifetime(changes) {} + } + + @Test("existing later turn content terminalizes command without inventing timing") + func existingLaterTurnContentTerminalizesCommandWithoutInventingTiming() + async throws + { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-existing-progress")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-command-existing-progress", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-existing-progress")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-existing-progress", + turnID: "turn-command-existing-progress" + ) + ) + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-command-existing-progress", + turnID: "turn-command-existing-progress", + itemID: "message-around-command" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-command-existing-progress", + turnID: "turn-command-existing-progress", + itemID: "message-around-command", + delta: "Before command" + ) + ) + let startedAt = Date().addingTimeInterval(-45) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-existing-progress", + turnID: "turn-command-existing-progress", + startedAtMs: Int64((startedAt.timeIntervalSince1970 * 1_000).rounded()), + item: .init( + id: "command-existing-progress", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + #expect(await eventually { + chat.items.contains { $0.itemID == "command-existing-progress" } + && chat.items.contains { $0.itemID == "message-around-command" } + }) + let commandItem = try #require(chat.items.first { $0.itemID == "command-existing-progress" }) + guard case .command(let startedCommand) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(startedCommand.status == .inProgress) + + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-command-existing-progress", + turnID: "turn-command-existing-progress", + itemID: "message-around-command", + delta: " after command" + ) + ) + + #expect(await eventually { + guard case .command(let command) = commandItem.content else { + return false + } + return command.status == .completed + && command.startedAt != nil + && command.completedAt == nil + && command.duration == nil + && chat.items.first { $0.itemID == "message-around-command" }?.text + == "Before command after command" + }) + withExtendedLifetime(changes) {} + } + + @Test("later turn content terminalizes command without inventing timing") + func laterTurnContentTerminalizesCommandWithoutInventingTiming() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-progress")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-command-progress", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-progress")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-progress", + turnID: "turn-command-progress" + ) + ) + let startedAt = Date().addingTimeInterval(-45) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-progress", + turnID: "turn-command-progress", + startedAtMs: Int64((startedAt.timeIntervalSince1970 * 1_000).rounded()), + item: .init( + id: "command-progress", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + #expect(await eventually { + chat.items.contains { $0.itemID == "command-progress" } + }) + let commandItem = try #require(chat.items.first { $0.itemID == "command-progress" }) + guard case .command(let startedCommand) = commandItem.content else { + Issue.record("Expected command item") + return + } + #expect(startedCommand.status == .inProgress) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-command-progress", + turnID: "turn-command-progress", + itemID: "message-after-command" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-command-progress", + turnID: "turn-command-progress", + itemID: "message-after-command", + delta: "Next step" + ) + ) + + #expect(await eventually { + guard case .command(let command) = commandItem.content else { + return false + } + return command.status == .completed + && command.startedAt != nil + && command.completedAt == nil + && command.duration == nil + && chat.items.contains { $0.itemID == "message-after-command" } + }) + withExtendedLifetime(changes) {} + } + + @Test("late prior command update does not regress terminalized lifecycle items") + func latePriorCommandUpdateDoesNotRegressTerminalizedLifecycleItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-command-late-update")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-command-late-update", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-command-late-update")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-command-late-update", + turnID: "turn-command-late-update" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-late-update", + turnID: "turn-command-late-update", + item: .init( + id: "command-first", + type: "commandExecution", + command: "git status" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-command-late-update", + turnID: "turn-command-late-update", + item: .init( + id: "command-second", + type: "commandExecution", + command: "git diff" + ) + ) + ) + + #expect(await eventually { + chat.items.contains { $0.itemID == "command-first" } + && chat.items.contains { $0.itemID == "command-second" } + }) + let firstCommand = try #require(chat.items.first { $0.itemID == "command-first" }) + let secondCommand = try #require(chat.items.first { $0.itemID == "command-second" }) + #expect(await eventually { + guard case .command(let first) = firstCommand.content, + case .command(let second) = secondCommand.content + else { + return false + } + return first.status == .completed && second.status == .inProgress + }) + + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-command-late-update", + turnID: "turn-command-late-update", + itemID: "command-first", + delta: "late output" + ) + ) + + #expect(await eventually { + guard case .command(let first) = firstCommand.content, + case .command(let second) = secondCommand.content + else { + return false + } + return first.status == .completed + && first.output == "late output" + && second.status == .inProgress + }) + withExtendedLifetime(changes) {} + } + + @Test("chat observation refreshes a snapshot and applies live events in place") + func chatObservationRefreshesSnapshotAndAppliesLiveEventsInPlace() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let completedAt = Date(timeIntervalSince1970: 4_000) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-live")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-live", + turns: [ + .init( + id: "turn-existing", + state: .completed, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + phase: .finalAnswer, + text: "Snapshot" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-live")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + let snapshotItem = try #require(chat.items.first) + #expect(observation.chat === chat) + #expect(chat.phase == .terminal( + turnID: "turn-existing", + disposition: .completed + )) + #expect(snapshotItem.text == "Snapshot") + + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-live", + turnID: "turn-existing", + item: .init( + id: "message-existing", + type: "agentMessage", + text: "Snapshot updated", + phase: "final_answer" + ) + ) + ) + #expect(await eventually { snapshotItem.text == "Snapshot updated" }) + #expect(chat.items.first === snapshotItem) + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-live", + turn: .init(id: "turn-existing", status: "completed") + ) + ) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-live", turnID: "turn-live") + ) + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-live", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-live", + turnID: "turn-live", + itemID: "message-live", + delta: "Hel", + phase: "final_answer" + ) + ) + #expect(await eventually { + chat.items.contains { $0.itemID == "message-live" && $0.text == "Hel" } + }) + let liveItem = try #require(chat.items.first { $0.itemID == "message-live" }) + + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-live", + turnID: "turn-live", + itemID: "message-live", + delta: "lo", + phase: "final_answer" + ) + ) + try await runtime.transport.emitServerNotification( + method: "thread/tokenUsage/updated", + params: TokenUsageParams( + threadID: "thread-live", + turnID: "turn-live", + tokenUsage: .init( + total: .init(inputTokens: 5, outputTokens: 7, totalTokens: 12), + modelContextWindow: 200_000 + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-live", turn: .init( + id: "turn-live", + status: "completed", + completedAt: Int(completedAt.timeIntervalSince1970) + )) + ) + + #expect(await eventually { + chat.turns.contains { $0.id == "turn-live" && $0.status == .completed } + && liveItem.text == "Hello" + && chat.phase == .terminal( + turnID: "turn-live", + disposition: .completed + ) + }) + let liveTurn = try #require(chat.turns.first { $0.id == "turn-live" }) + #expect(chat.items.first { $0.itemID == "message-live" } === liveItem) + #expect(liveTurn.usage?.totalTokens == 12) + #expect(liveTurn.usage?.modelContextWindow == 200_000) + #expect(chat.updatedAt == completedAt) + #expect(chat.transcript.finalAnswer == "Hello") + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + withExtendedLifetime(changes) {} + + observation.cancel() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-live")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-live", + turns: [ + .init( + id: "turn-live", + state: .completed, + items: [ + .init( + id: "message-live", + kind: .agentMessage, + content: .message(.init( + id: "message-live", + role: .assistant, + phase: .finalAnswer, + text: "Hello" + )) + ), + ] + ), + ] + )) + + let restartedObservation = try await chat.observe() + defer { + restartedObservation.cancel() + } + + #expect(await eventually { + chat.items.first { $0.itemID == "message-live" }?.text == "Hello" + }) + #expect(chat.items.filter { $0.itemID == "message-live" }.count == 1) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 2) + } + + @Test("chat observation shares its pump and upgrades include-turn hydration") + func chatObservationSharesPumpAndUpgradesIncludeTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-upgrade")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-upgrade")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-upgrade")) + let metadataObservation = try await chat.observe(includeTurns: false) + defer { + metadataObservation.cancel() + } + + #expect(chat.turn(id: "turn-history") == nil) + + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-upgrade", + turns: [ + .init( + id: "turn-history", + state: .completed, + items: [ + .init( + id: "message-history", + kind: .agentMessage, + content: .message(.init( + id: "message-history", + role: .assistant, + phase: .finalAnswer, + text: "Loaded from upgrade" + )) + ), + ] + ), + ] + )) + + let turnObservation = try await chat.observe(includeTurns: true) + defer { + turnObservation.cancel() + } + + let turn = try #require(chat.turn(id: "turn-history")) + #expect(turn.status == .completed) + #expect(chat.items(in: "turn-history").map(\.text) == ["Loaded from upgrade"]) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + let readRequests = await runtime.transport.recordedRequests(method: "thread/read") + #expect(readRequests.count == 2) + let firstParams = try readRequests[0].decodeParams(ThreadReadParams.self) + let secondParams = try readRequests[1].decodeParams(ThreadReadParams.self) + #expect(firstParams.includeTurns == false) + #expect(secondParams.includeTurns == true) + } + + @Test("include-turn join waits for in-flight observation start then upgrades once") + func includeTurnJoinWaitsForStartThenUpgradesOnce() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let readGate = CodexAppServerTestGate() + let chat = context.model(for: CodexThreadID(rawValue: "thread-join-upgrade")) + var metadataObservation: CodexChatObservation? + var turnsObservation: CodexChatObservation? + + try await runtime.transport.enqueueThreadResume(.init(id: chat.id)) + try await runtime.transport.enqueueThreadRead(.init(id: chat.id, status: .idle)) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/read", + gate: readGate + ) + try await runtime.transport.enqueueThreadRead(.init( + id: chat.id, + status: .idle, + turns: [.init(id: "turn-joined", state: .completed)] + )) + + let metadataStart = Task { @MainActor in + do { metadataObservation = try await chat.observe(includeTurns: false) } + catch { Issue.record("Metadata observation failed: \(error)") } + } + await runtime.transport.waitForRequest(method: "thread/read", count: 1) + let turnsJoin = Task { @MainActor in + do { turnsObservation = try await chat.observe(includeTurns: true) } + catch { Issue.record("Turns observation failed: \(error)") } + } + + await readGate.open() + await metadataStart.value + await turnsJoin.value + defer { + metadataObservation?.cancel() + turnsObservation?.cancel() + } + + #expect(metadataObservation != nil) + #expect(turnsObservation != nil) + #expect(chat.turn(id: "turn-joined")?.status == .completed) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + let reads = await runtime.transport.recordedRequests(method: "thread/read") + #expect(reads.count == 2) + #expect(try reads.map { try $0.decodeParams(ThreadReadParams.self).includeTurns } + == [false, true]) + } + + @Test("cancelling one observation start waiter preserves the shared start") + func cancellingObservationStartWaiterPreservesSharedStart() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let readGate = CodexAppServerTestGate() + let chat = context.model(for: CodexThreadID(rawValue: "thread-join-cancel")) + + try await runtime.transport.enqueueThreadResume(.init(id: chat.id)) + try await runtime.transport.enqueueThreadRead(.init(id: chat.id, status: .idle)) + await runtime.transport.holdNextIgnoringCancellation( + method: "thread/read", + gate: readGate + ) + + var firstWasCancelled = false + var secondObservation: CodexChatObservation? + let firstStart = Task { @MainActor in + do { + _ = try await chat.observe(includeTurns: false) + Issue.record("Expected the first observation waiter to be cancelled") + } catch is CancellationError { + firstWasCancelled = true + } catch { + Issue.record("Unexpected first observation failure: \(error)") + } + } + await runtime.transport.waitForRequest(method: "thread/read") + let secondStart = Task { @MainActor in + do { + secondObservation = try await chat.observe(includeTurns: false) + } catch { + Issue.record("Unexpected second observation failure: \(error)") + } + } + await Task.yield() + + firstStart.cancel() + await firstStart.value + + #expect(firstWasCancelled) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + await readGate.open() + await secondStart.value + let observation = try #require(secondObservation) + defer { observation.cancel() } + + #expect(observation.chat === chat) + #expect(chat.phase == .idle) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + #expect(await runtime.transport.recordedRequests(method: "thread/read").count == 1) + } + + @Test("finished chat observations are not reused") + func finishedChatObservationsAreNotReused() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-finished")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-finished")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-finished")) + let firstObservation = try await chat.observe() + defer { + firstObservation.cancel() + } + let firstChanges = ChatUpdateRecorder(stream: firstObservation.updates) + + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadClosedParams(threadID: "thread-finished") + ) + #expect(await eventually { chat.status == .notLoaded && chat.phase == .idle }) + #expect(await eventually { firstChanges.isFinished }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-finished")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-finished", + turns: [.init(id: "turn-restarted", state: .completed)] + )) + + let restartedObservation = try await chat.observe() + defer { + restartedObservation.cancel() + } + let restartedChanges = ChatUpdateRecorder(stream: restartedObservation.updates) + + #expect(chat.turn(id: "turn-restarted") != nil) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 2) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-finished", + turnID: "turn-restarted", + itemID: "message-restarted", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-finished", + turnID: "turn-restarted", + itemID: "message-restarted", + delta: "Live after restart", + phase: "final_answer" + ) + ) + + #expect(await eventually { + chat.items.first { $0.itemID == "message-restarted" }?.text == "Live after restart" + }) + withExtendedLifetime(restartedChanges) {} + } + + @Test("chat observation change streams finish when setup consumes terminal events") + func chatObservationChangeStreamsFinishWhenSetupConsumesTerminalEvents() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-terminal-setup")) + await runtime.transport.holdNext(method: "thread/read", gate: gate) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-terminal-setup")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-terminal-setup")) + var observedChat: CodexChatObservation? + let observeTask = Task { @MainActor in + observedChat = try await chat.observe() + } + + await runtime.transport.waitForRequest(method: "thread/read") + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadClosedParams(threadID: "thread-terminal-setup") + ) + await gate.open() + + try await observeTask.value + let observation = try #require(observedChat) + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + #expect(observation.chat === chat) + #expect(observation.chat.id == "thread-terminal-setup") + #expect(await eventually { changes.isFinished }) + } + + @Test("chat observation keeps refreshed output snapshots idempotent with replayed deltas") + func chatObservationKeepsRefreshedOutputSnapshotsIdempotentWithReplayedDeltas() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-replay", + turnID: "turn-replay", + item: .init( + id: "command-replay", + type: "commandExecution", + command: "echo hello", + output: "" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-replay", + turnID: "turn-replay", + itemID: "command-replay", + delta: "Hel" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-replay", + turnID: "turn-replay", + itemID: "command-replay", + delta: "lo" + ) + ) + try await runtime.transport.enqueueThreadResume(.init(id: "thread-replay")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-replay", + turns: [ + .init( + id: "turn-replay", + state: .inProgress, + items: [ + .init( + id: "command-replay", + kind: .commandExecution, + content: .command(.init(command: "echo hello", output: "Hello")) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-replay")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + + try? await Task.sleep(for: .milliseconds(100)) + + #expect(chat.items.first { $0.itemID == "command-replay" }?.text == "Hello") + #expect(chat.items.filter { $0.itemID == "command-replay" }.count == 1) + } + + @Test("chat observation keeps refreshed message snapshots idempotent with buffered replayed deltas") + func chatObservationKeepsRefreshedMessageSnapshotsIdempotentWithBufferedReplayedDeltas() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let gate = CodexAppServerTestGate() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-message-replay")) + await runtime.transport.holdNext(method: "thread/read", gate: gate) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-message-replay", + turns: [ + .init( + id: "turn-message-replay", + state: .inProgress, + items: [ + .init( + id: "message-replay", + kind: .agentMessage, + content: .message(.init( + id: "message-replay", + role: .assistant, + phase: .finalAnswer, + text: "Hello" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-message-replay")) + var observedChat: CodexChatObservation? + let observeTask = Task { @MainActor in + observedChat = try await chat.observe() + } + await runtime.transport.waitForRequest(method: "thread/read") + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-message-replay", + turnID: "turn-message-replay", + itemID: "message-replay", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-message-replay", + turnID: "turn-message-replay", + itemID: "message-replay", + delta: "Hel", + phase: "final_answer" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-message-replay", + turnID: "turn-message-replay", + itemID: "message-replay", + delta: "lo", + phase: "final_answer" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-message-replay", + turnID: "turn-message-replay", + itemID: "message-replay", + delta: " world", + phase: "final_answer" + ) + ) + await gate.open() + + try await observeTask.value + let observation = try #require(observedChat) + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + #expect(await eventually { + chat.items.first { $0.itemID == "message-replay" }?.text == "Hello world" + }) + #expect(chat.items.filter { $0.itemID == "message-replay" }.count == 1) + withExtendedLifetime(changes) {} + } + + @Test("duplicate chat observations create independent subscriber leases") + func duplicateChatObservationsCreateIndependentLeases() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-duplicate")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-duplicate")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-duplicate")) + let firstObservation = try await chat.observe() + defer { + firstObservation.cancel() + } + + let secondObservation = try await chat.observe() + defer { secondObservation.cancel() } + #expect(secondObservation !== firstObservation) + #expect(secondObservation.chat === chat) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + } + + @Test("chat observation preserves distinct repeated narrative snapshot items") + func chatObservationPreservesDistinctRepeatedNarrativeSnapshotItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-duplicate-history")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-duplicate-history", + turns: [ + .init( + id: "turn-duplicate-history", + state: .inProgress, + items: [ + .init( + id: "review-a", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "user-a", + kind: .userMessage, + content: .message(.init( + id: "user-a", + role: .user, + text: "Review current changes" + )) + ), + .init( + id: "answer-a", + kind: .agentMessage, + content: .message(.init( + id: "answer-a", + role: .assistant, + phase: .finalAnswer, + text: "OK" + )) + ), + .init( + id: "reasoning-a", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking diff")) + ), + .init( + id: "command-a", + kind: .commandExecution, + content: .command(.init(command: "/bin/zsh -lc")) + ), + .init( + id: "user-b", + kind: .userMessage, + content: .message(.init( + id: "user-b", + role: .user, + text: "Review current changes" + )) + ), + .init( + id: "answer-b", + kind: .agentMessage, + content: .message(.init( + id: "answer-b", + role: .assistant, + phase: .finalAnswer, + text: "OK" + )) + ), + .init( + id: "reasoning-b", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking diff")) + ), + .init( + id: "command-b", + kind: .commandExecution, + content: .command(.init(command: "/bin/zsh -lc")) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-duplicate-history")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + + #expect(chat.items.map(\.itemID) == [ + "review-a", + "user-a", + "answer-a", + "reasoning-a", + "command-a", + "user-b", + "answer-b", + "reasoning-b", + "command-b", + ]) + } + + @Test("chat observation preserves replay narrative snapshot items across turns") + func chatObservationPreservesReplayNarrativeSnapshotItemsAcrossTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-replay-history")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-replay-history", + turns: [ + .init( + id: "turn-replay-a", + state: .completed, + items: [ + .init( + id: "review-a", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "user-a", + kind: .userMessage, + content: .message(.init( + id: "user-a", + role: .user, + text: "Review current changes" + )) + ), + .init( + id: "reasoning-a", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking diff")) + ), + .init( + id: "answer-a", + kind: .agentMessage, + content: .message(.init( + id: "answer-a", + role: .assistant, + phase: .finalAnswer, + text: "Same final answer" + )) + ), + ] + ), + .init( + id: "turn-replay-b", + state: .completed, + items: [ + .init( + id: "review-b", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "user-b", + kind: .userMessage, + content: .message(.init( + id: "user-b", + role: .user, + text: "Review current changes" + )) + ), + .init( + id: "reasoning-b", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking diff")) + ), + .init( + id: "answer-b", + kind: .agentMessage, + content: .message(.init( + id: "answer-b", + role: .assistant, + phase: .finalAnswer, + text: "Same final answer" + )) + ), + ] + ), + .init( + id: "turn-replay-c", + state: .completed, + items: [ + .init( + id: "reasoning-c", + kind: .reasoning, + content: .reasoning(.init( + summary: ["Checking diff"], + content: ["Distinct raw trace"] + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-replay-history")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + + #expect(chat.items.map(\.itemID) == [ + "review-a", + "user-a", + "reasoning-a", + "answer-a", + "review-b", + "user-b", + "reasoning-b", + "answer-b", + "reasoning-c", + ]) + } + + @Test("chat observation preserves replay narrative live items across turns") + func chatObservationPreservesReplayNarrativeLiveItemsAcrossTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-replay-live")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-replay-live", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-replay-live")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + for turnID in ["turn-replay-a", "turn-replay-b"] { + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-replay-live", + turnID: turnID + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-replay-live", + turnID: turnID, + item: .init( + id: "reasoning-\(turnID)", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-replay-live", + turnID: turnID, + item: .init( + id: "diagnostic-\(turnID)", + type: "agentMessage", + text: "Review was interrupted." + ) + ) + ) + #expect(await eventually { + chat.items.contains { $0.itemID == "reasoning-\(turnID)" } + && chat.items.contains { $0.itemID == "diagnostic-\(turnID)" } + }) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-replay-live", + turn: .init(id: turnID, status: "completed") + ) + ) + } + + #expect(await eventually { + chat.items.map(\.itemID) == [ + "reasoning-turn-replay-a", + "diagnostic-turn-replay-a", + "reasoning-turn-replay-b", + "diagnostic-turn-replay-b", + ] + }) + withExtendedLifetime(changes) {} + } + + @Test("chat observation removes reasoning parts only within the same turn") + func chatObservationRemovesReasoningPartsOnlyWithinSameTurn() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-reasoning-parts")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-reasoning-parts", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-reasoning-parts")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-reasoning-parts", turnID: "turn-a") + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-reasoning-parts", + turnID: "turn-a", + item: .init( + id: "reasoning-parent:summary:0", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + #expect(await eventually { + chat.items.contains { + $0.turnID == "turn-a" && $0.itemID == "reasoning-parent:summary:0" + } + }) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-reasoning-parts", + turn: .init(id: "turn-a", status: "completed") + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams(threadID: "thread-reasoning-parts", turnID: "turn-b") + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-reasoning-parts", + turnID: "turn-b", + item: .init( + id: "reasoning-parent:summary:0", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-reasoning-parts", + turnID: "turn-b", + item: .init( + id: "reasoning-parent", + type: "reasoning", + text: "Checked diff" + ) + ) + ) + + #expect(await eventually { + chat.items.map { "\($0.turnID?.rawValue ?? "nil"):\($0.itemID)" } == [ + "turn-a:reasoning-parent:summary:0", + "turn-b:reasoning-parent", + ] + }) + guard case .itemRemoved(let removedItem) = + await changes.itemRemoved(id: "reasoning-parent:summary:0") + else { + Issue.record("Expected reasoning part removal.") + return + } + #expect(removedItem.id == "reasoning-parent:summary:0") + #expect(removedItem.kind == .reasoning) + #expect(removedItem.turnID == "turn-b") + } + + @Test("chat observation preserves distinct repeated narrative live items") + func chatObservationPreservesDistinctRepeatedNarrativeLiveItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-duplicate-live")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-duplicate-live", turns: [])) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-duplicate-live")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "review-a", + type: "enteredReviewMode", + text: "current changes" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "diagnostic-a", + type: "agentMessage", + text: "Repeated diagnostic" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "reasoning-a", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "diagnostic-b", + type: "agentMessage", + text: "Repeated diagnostic" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "command-a", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "reasoning-b", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-duplicate-live", + turnID: "turn-duplicate-live", + item: .init( + id: "command-b", + type: "commandExecution", + command: "/bin/zsh -lc" + ) + ) + ) + + let expectedItemIDs = [ + "review-a", + "reasoning-a", + "diagnostic-a", + "command-a", + "reasoning-b", + "diagnostic-b", + "command-b", + ] + #expect(await eventually { + chat.items.count >= expectedItemIDs.count + }) + #expect(chat.items.count == expectedItemIDs.count) + #expect(Set(chat.items.map(\.itemID)) == Set(expectedItemIDs)) + withExtendedLifetime(changes) {} + } + + @Test("chat observations stream snapshots and item text changes") + func chatObservationsStreamSnapshotsAndItemTextChanges() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-changes")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-changes", + turns: [ + .init( + id: "turn-existing", + state: .completed, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + phase: .finalAnswer, + text: "Snapshot" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-changes")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + #expect(observation.chat === chat) + #expect(chat.items.map(\.text) == ["Snapshot"]) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-changes", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-changes", + turnID: "turn-live", + itemID: "message-live", + delta: "Hel", + phase: "final_answer" + ) + ) + + let insertedChange = await changes.itemInserted(id: "message-live") + #expect(insertedChange != nil) + let initialTextChange = await changes.itemTextAppended( + id: "message-live", + delta: "Hel" + ) + #expect(initialTextChange != nil) + #expect(chat.items.first { $0.itemID == "message-live" }?.text == "Hel") + + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-changes", + turnID: "turn-live", + itemID: "message-live", + delta: "lo", + phase: "final_answer" + ) + ) + + guard case .itemTextAppended(let item, let delta) = + await changes.itemTextAppended(id: "message-live", delta: "lo") + else { + Issue.record("Expected appended text change.") + return + } + #expect(item.id == "message-live") + #expect(item.kind == .agentMessage) + #expect(item.turnID == "turn-live") + #expect(delta == "lo") + #expect(chat.items.first { $0.itemID == "message-live" }?.text == "Hello") + + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-changes", + turnID: "turn-live", + item: .init( + id: "message-live", + type: "agentMessage", + text: "Rewritten", + phase: "final_answer" + ) + ) + ) + + let updatedChange = await changes.itemUpdated(id: "message-live") + #expect(updatedChange != nil) + #expect(chat.items.first { $0.itemID == "message-live" }?.text == "Rewritten") + } + + @Test("chat item identity preserves kind changes from baseline to live updates") + func chatItemIdentityPreservesKindChangesFromBaselineToLiveUpdates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + let chat = context.model(for: CodexThreadID(rawValue: "thread-kind-change")) + chat.apply( + CodexThreadSnapshot( + id: chat.id, + turns: [ + .init( + id: "turn-kind-change", + state: .inProgress, + items: [ + .init( + id: "item-kind-change", + kind: .unknown("progress"), + content: .diagnostic("Initial") + ), + ] + ), + ] + ), + workspace: Optional.none + ) + let originalItem = try #require(chat.items.first) + + let changes = chat.apply(CodexThreadEvent.itemUpdated( + .init( + id: "item-kind-change", + kind: .diagnostic, + content: .diagnostic("Updated") + ), + turnID: "turn-kind-change" + )) + + #expect(chat.items.count == 2) + let originalItems = chat.items.filter { + $0.kind == .unknown("progress") && $0.text == "Initial" + } + let diagnosticItems = chat.items.filter { + $0.kind == .diagnostic && $0.text == "Updated" + } + #expect(originalItems.count == 1) + #expect(originalItems.first === originalItem) + #expect(diagnosticItems.count == 1) + let diagnosticItem = try #require(diagnosticItems.first) + #expect(changes.contains(.itemInserted( + id: diagnosticItem.id, + turnID: "turn-kind-change" + ))) + } + + @Test("tool call progress updates preserve existing metadata") + func toolCallProgressUpdatesPreserveExistingMetadata() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-tool-progress")) + chat.apply( + CodexThreadSnapshot( + id: chat.id, + turns: [ + .init( + id: "turn-tool-progress", + state: .inProgress, + items: [ + .init( + id: "tool-progress", + kind: .mcpToolCall, + content: .toolCall(.init( + namespace: "mcp", + server: "github", + name: "search_issues", + arguments: #"{"q":"is:open"}"#, + status: .inProgress + )) + ), + ] + ), + ] + ), + workspace: Optional.none + ) + let toolItem = try #require(chat.items.first { $0.itemID == "tool-progress" }) + + let changes = chat.apply(CodexThreadEvent.itemUpdated( + .init( + id: "tool-progress", + kind: .mcpToolCall, + content: .toolCall(.init(result: "Searching GitHub")) + ), + turnID: "turn-tool-progress" + )) + + #expect(changes.contains(.itemUpdated( + id: toolItem.id, + turnID: "turn-tool-progress" + ))) + guard case .toolCall(let toolCall) = toolItem.content else { + Issue.record("Expected tool call item") + return + } + #expect(toolCall.namespace == "mcp") + #expect(toolCall.server == "github") + #expect(toolCall.name == "search_issues") + #expect(toolCall.arguments == #"{"q":"is:open"}"#) + #expect(toolCall.result == "Searching GitHub") + #expect(toolCall.status == .inProgress) + } + + @Test("active chat refresh emits snapshots after phase reconciliation") + func activeChatRefreshEmitsSnapshotsAfterPhaseReconciliation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-stream")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-stream", + status: .active(activeFlags: []), + turns: [.init(id: "turn-running", state: .inProgress)] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-stream")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + #expect(chat.phase == .running(turnID: "turn-running")) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-stream")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-stream", + status: .idle, + turns: [] + )) + + try await context.refresh(chat) + + #expect(await changes.snapshot(reason: .refresh) != nil) + #expect(chat.phase == .idle) + } + + @Test("active chat refresh preserves live-streamed items omitted by lagging snapshots") + func activeChatRefreshPreservesLiveStreamedItemsOmittedByLaggingSnapshots() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-live")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-live", + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-existing", + state: .inProgress, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + text: "Snapshot baseline" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-live")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-refresh-live", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-refresh-live", + turnID: "turn-live", + itemID: "message-live", + delta: "Live update", + phase: "final_answer" + ) + ) + #expect(await changes.itemInserted(id: "message-live") != nil) + let liveItem = try #require(chat.items.first { $0.itemID == "message-live" }) + + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-existing", + state: .inProgress, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + text: "Snapshot baseline" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-live", + status: .active(activeFlags: []) + )) + + try await context.refresh(chat) + + #expect(chat.items.first { $0.itemID == "message-live" } === liveItem) + #expect(chat.items.first { $0.itemID == "message-live" }?.text == "Live update") + #expect(chat.turns.contains { $0.id == "turn-live" }) + } + + @Test("active chat refresh preserves replay reasoning across turns") + func activeChatRefreshPreservesReplayReasoningAcrossTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-replay")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-replay", + status: .active(activeFlags: []), + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-replay")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-refresh-replay", + turnID: "turn-live", + item: .init( + id: "reasoning-live", + type: "reasoning", + text: "Checking diff" + ) + ) + ) + #expect(await changes.itemInserted(id: "reasoning-live") != nil) + + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-snapshot", + state: .inProgress, + items: [ + .init( + id: "reasoning-snapshot", + kind: .reasoning, + content: .reasoning(.init(summary: "Checking diff")) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-replay", + status: .active(activeFlags: []) + )) + + try await context.refresh(chat) + + #expect(chat.items.map(\.itemID) == ["reasoning-live", "reasoning-snapshot"]) + #expect(chat.turns.contains { $0.id == "turn-live" }) + #expect(chat.turns.contains { $0.id == "turn-snapshot" }) + } + + @Test("terminal chat refresh replaces live-streamed items with authoritative snapshot") + func terminalChatRefreshReplacesLiveStreamedItemsWithAuthoritativeSnapshot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-terminal")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-terminal", + status: .active(activeFlags: []), + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-terminal")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-refresh-terminal", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-refresh-terminal", + turnID: "turn-live", + itemID: "message-live", + delta: "Live duplicate", + phase: "final_answer" + ) + ) + #expect(await changes.itemInserted(id: "message-live") != nil) + #expect(chat.items.map(\.itemID) == ["message-live"]) + + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-authoritative", + state: .completed, + items: [ + .init( + id: "message-authoritative", + kind: .agentMessage, + content: .message(.init( + id: "message-authoritative", + role: .assistant, + text: "Authoritative" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-terminal", + status: .idle + )) + + try await context.refresh(chat) + + #expect(chat.turns.map(\.id.rawValue) == ["turn-authoritative"]) + #expect(chat.items.map(\.itemID) == ["message-authoritative"]) + #expect(chat.items.map(\.text) == ["Authoritative"]) + } + + @Test("terminal snapshot does not invent per-command completion timestamps") + func terminalSnapshotDoesNotInventPerCommandCompletionTimestamps() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID: CodexThreadID = "thread-terminal-command-timestamps" + + try await runtime.transport.enqueueThreadResume(.init(id: threadID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-terminal-command-timestamps", + state: .completed, + items: [ + .init( + id: "command-early", + kind: .commandExecution, + content: .command(.init( + command: "first", + startedAt: Date(timeIntervalSince1970: 4_000) + )) + ), + .init( + id: "command-late", + kind: .commandExecution, + content: .command(.init( + command: "second", + startedAt: Date(timeIntervalSince1970: 4_500) + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: threadID, + updatedAt: Date(timeIntervalSince1970: 5_000), + status: .idle + )) + + let chat = context.model(for: threadID) + try await context.refresh(chat) + + #expect(chat.items.count == 2) + for item in chat.items { + guard case .command(let command) = item.content else { + Issue.record("Expected command item") + continue + } + #expect(command.status == .completed) + #expect(command.completedAt == nil) + #expect(command.duration == nil) + } + } + + @Test("later snapshot content terminalizes a command without inventing timing") + func laterSnapshotContentTerminalizesCommandWithoutInventingTiming() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID: CodexThreadID = "thread-active-command-timestamps" + + try await runtime.transport.enqueueThreadResume(.init(id: threadID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-active-command-timestamps", + state: .inProgress, + items: [ + .init( + id: "command-before-message", + kind: .commandExecution, + content: .command(.init( + command: "git status", + status: .inProgress, + startedAt: Date(timeIntervalSince1970: 4_000) + )) + ), + .init( + id: "message-after-command", + kind: .agentMessage, + content: .message(.init( + id: "message-after-command", + role: .assistant, + text: "Done" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: threadID, + updatedAt: Date(timeIntervalSince1970: 5_000), + status: .active(activeFlags: []) + )) + + let chat = context.model(for: threadID) + try await context.refresh(chat) + + let item = try #require(chat.items.first { + $0.itemID == "command-before-message" + }) + guard case .command(let command) = item.content else { + Issue.record("Expected command item") + return + } + #expect(chat.turn(id: "turn-active-command-timestamps")?.state == .inProgress) + #expect(command.status == .completed) + #expect(command.startedAt == nil) + #expect(command.completedAt == nil) + #expect(command.duration == nil) + } + + @Test("snapshot item order preserves interrupted and failed turn dispositions") + func snapshotItemOrderPreservesTerminalTurnDispositions() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let threadID: CodexThreadID = "thread-terminal-command-dispositions" + + func items(commandID: String, messageID: String) -> [CodexThreadItem] { + [ + .init( + id: commandID, + kind: .commandExecution, + content: .command(.init( + command: "git status", + status: .inProgress + )) + ), + .init( + id: messageID, + kind: .agentMessage, + content: .message(.init( + id: messageID, + role: .assistant, + text: "Stopped" + )) + ), + ] + } + + try await runtime.transport.enqueueThreadResume(.init(id: threadID)) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-interrupted-command", + state: .interrupted, + items: items( + commandID: "command-interrupted", + messageID: "message-interrupted" + ) + ), + .init( + id: "turn-failed-command", + state: .failed(.init(message: "failed")), + items: items( + commandID: "command-failed", + messageID: "message-failed" + ) + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: threadID, + status: .idle + )) + + let chat = context.model(for: threadID) + try await context.refresh(chat) + + let interruptedItem = try #require(chat.items.first { + $0.itemID == "command-interrupted" + }) + let failedItem = try #require(chat.items.first { + $0.itemID == "command-failed" + }) + guard case .command(let interruptedCommand) = interruptedItem.content, + case .command(let failedCommand) = failedItem.content + else { + Issue.record("Expected command items") + return + } + #expect(interruptedCommand.status == .interrupted) + #expect(failedCommand.status == .failed) + #expect(interruptedCommand.duration == nil) + #expect(failedCommand.duration == nil) + } + + @Test("not-loaded metadata refresh replaces live-streamed items with authoritative turns") + func notLoadedMetadataRefreshReplacesLiveStreamedItemsWithAuthoritativeTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-not-loaded")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-not-loaded", + status: .active(activeFlags: []), + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-not-loaded")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-refresh-not-loaded", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-refresh-not-loaded", + turnID: "turn-live", + itemID: "message-live", + delta: "Live duplicate", + phase: "final_answer" + ) + ) + #expect(await changes.itemInserted(id: "message-live") != nil) + #expect(chat.items.map(\.itemID) == ["message-live"]) + + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-authoritative", + state: .completed, + items: [ + .init( + id: "message-authoritative", + kind: .agentMessage, + content: .message(.init( + id: "message-authoritative", + role: .assistant, + text: "Authoritative interruption" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-not-loaded", + status: .notLoaded + )) + + try await context.refresh(chat) + + #expect(chat.status == .notLoaded) + #expect(chat.turns.map(\.id.rawValue) == ["turn-authoritative"]) + #expect(chat.items.map(\.itemID) == ["message-authoritative"]) + #expect(chat.items.map(\.text) == ["Authoritative interruption"]) + } + + @Test("mixed snapshot merge removes stale full turn items") + func mixedSnapshotMergeRemovesStaleFullTurnItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-mixed-snapshot-stale-items")) + let fullTurnID = CodexTurnID(rawValue: "turn-full") + let summaryTurnID = CodexTurnID(rawValue: "turn-summary") + func messageItem(_ id: String, text: String) -> CodexThreadItem { + CodexThreadItem( + id: id, + kind: .agentMessage, + content: .message(.init(id: id, role: .assistant, text: text)) + ) + } + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: fullTurnID, + state: .inProgress, + itemsLoadState: .full, + items: [ + messageItem("message-kept", text: "Keep me"), + messageItem("message-stale", text: "Remove me"), + ] + ), + .init( + id: summaryTurnID, + state: .inProgress, + itemsLoadState: .summary, + items: [] + ), + ] + ), + workspace: Optional.none + ) + #expect(chat.items.map(\.itemID) == ["message-kept", "message-stale"]) + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: fullTurnID, + state: .inProgress, + itemsLoadState: .full, + items: [ + messageItem("message-kept", text: "Still here"), + ] + ), + .init( + id: summaryTurnID, + state: .inProgress, + itemsLoadState: .summary, + items: [] + ), + ] + ), + workspace: Optional.none + ) + + #expect(chat.items.map(\.itemID) == ["message-kept"]) + #expect(chat.items.first?.text == "Still here") + #expect(chat.turns.map(\.id) == [fullTurnID, summaryTurnID]) + } + + @Test("not-loaded metadata fallback preserves live-streamed items omitted by turns") + func notLoadedMetadataFallbackPreservesLiveStreamedItemsOmittedByTurns() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-not-loaded-fallback")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-refresh-not-loaded-fallback", + status: .active(activeFlags: []), + turns: [] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-not-loaded-fallback")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-refresh-not-loaded-fallback", + turnID: "turn-live" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-refresh-not-loaded-fallback", + turnID: "turn-live", + item: .init( + id: "command-live", + type: "commandExecution", + command: "/bin/zsh -lc 'git status --short'" + ) + ) + ) + #expect(await changes.itemInserted(id: "command-live") != nil) + let liveCommand = try #require(chat.items.first { $0.itemID == "command-live" }) + + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-summary", + state: .interrupted, + items: [ + .init( + id: "message-interrupted", + kind: .agentMessage, + content: .message(.init( + id: "message-interrupted", + role: .assistant, + text: "Review was interrupted." + )) + ), + ] + ), + ])) + await runtime.transport.enqueueFailure( + code: -32_004, + message: "thread not loaded: thread-refresh-not-loaded-fallback", + for: "thread/read" + ) + + try await context.refresh(chat) + + #expect(chat.status == .notLoaded) + #expect(chat.items.first { $0.itemID == "command-live" } === liveCommand) + #expect(chat.items.first { $0.itemID == "message-interrupted" }?.text == "Review was interrupted.") + #expect(chat.items.map(\.itemID).contains("command-live")) + #expect(chat.items.map(\.itemID).contains("message-interrupted")) + } + + @Test("restarted chat observation preserves prior live-streamed items omitted by lagging snapshots") + func restartedChatObservationPreservesPriorLiveStreamedItemsOmittedByLaggingSnapshots() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-reobserve-live")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-reobserve-live", + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-existing", + state: .inProgress, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + text: "Snapshot baseline" + )) + ), + ] + ), + ] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-reobserve-live")) + let observation = try await chat.observe() + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-reobserve-live", + turnID: "turn-live", + itemID: "message-live", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-reobserve-live", + turnID: "turn-live", + itemID: "message-live", + delta: "Live update", + phase: "final_answer" + ) + ) + #expect(await changes.itemInserted(id: "message-live") != nil) + let liveItem = try #require(chat.items.first { $0.itemID == "message-live" }) + let liveTurn = try #require(chat.turn(id: "turn-live")) + + observation.cancel() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-reobserve-live")) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-existing", + state: .inProgress, + items: [ + .init( + id: "message-existing", + kind: .agentMessage, + content: .message(.init( + id: "message-existing", + role: .assistant, + text: "Snapshot baseline" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-reobserve-live", + status: .active(activeFlags: []) + )) + + let restartedObservation = try await chat.observe() + defer { + restartedObservation.cancel() + } + + #expect(chat.items.first { $0.itemID == "message-live" } === liveItem) + #expect(chat.items.first { $0.itemID == "message-live" }?.text == "Live update") + #expect(chat.turn(id: "turn-live") === liveTurn) + #expect(chat.items.map(\.itemID).filter { $0 == "message-live" }.count == 1) + } + + @Test("active chat refresh applies buffered live events after read failure") + func activeChatRefreshAppliesBufferedLiveEventsAfterReadFailure() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-failure")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-refresh-failure")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-refresh-failure")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + let gate = CodexAppServerTestGate() + try await runtime.transport.enqueueThreadResume(.init(id: "thread-refresh-failure")) + await runtime.transport.holdNext(method: "thread/read", gate: gate) + await runtime.transport.enqueueFailure( + code: -32000, + message: "read failed", + for: "thread/read" + ) + + let refreshTask = Task { + try await context.refresh(chat) + } + + await runtime.transport.waitForRequest(method: "thread/read", count: 2) + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-refresh-failure", + turnID: "turn-buffered", + itemID: "message-buffered", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-refresh-failure", + turnID: "turn-buffered", + itemID: "message-buffered", + delta: "Buffered", + phase: "final_answer" + ) + ) + await gate.open() + + do { + _ = try await refreshTask.value + Issue.record("Expected refresh to throw.") + } catch { + } + + let inserted = await changes.itemInserted(id: "message-buffered") + #expect(inserted != nil) + #expect(chat.items.first { $0.itemID == "message-buffered" }?.text == "Buffered") + } + + @Test("active chat observation owns one pump with independent streams") + func activeChatObservationOwnsOnePumpWithIndependentStreams() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-shared-changes")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-shared-changes")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-shared-changes")) + let firstObservation = try await chat.observe() + defer { + firstObservation.cancel() + } + #expect(firstObservation.chat === chat) + + let secondObservation = try await chat.observe() + defer { secondObservation.cancel() } + #expect(secondObservation !== firstObservation) + #expect(secondObservation.chat === chat) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + } + + @Test("chat observation preserves loading phase for active thread snapshots") + func chatObservationPreservesLoadingPhaseForActiveThreadSnapshots() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-running")) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-running", + status: .active(activeFlags: []), + turns: [.init(id: "turn-running", state: .inProgress)] + )) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-running")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + + #expect(chat.phase == .running(turnID: "turn-running")) + #expect(chat.turn(id: "turn-running")?.status == .inProgress) + } + + @Test("thread closed notifications preserve failed chat phase") + func threadClosedNotificationsPreserveFailedChatPhase() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-failed")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-failed")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-failed")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams( + threadID: "thread-failed", + turn: .init( + id: "turn-failed", + status: "failed", + error: .init( + message: "Tool failed", + codexErrorInfo: "serverOverloaded", + additionalDetails: "upstream detail" + ) + ) + ) + ) + + #expect(await eventually { + chat.phase == .terminal(turnID: "turn-failed", disposition: .failed) + }) + #expect(chat.turn(id: "turn-failed")?.error == .init( + message: "Tool failed", + info: .serverOverloaded, + additionalDetails: "upstream detail" + )) + + try await runtime.transport.emitServerNotification( + method: "thread/status/changed", + params: ThreadStatusParams(threadID: "thread-failed", status: .init(type: "idle")) + ) + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadClosedParams(threadID: "thread-failed") + ) + + #expect(await eventually { + chat.phase == .terminal(turnID: "turn-failed", disposition: .failed) + }) + withExtendedLifetime(changes) {} + } + + @Test("thread closed notifications clear active chat status") + func threadClosedNotificationsClearActiveChatStatus() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-closed-status")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-closed-status")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-closed-status")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "thread/status/changed", + params: ThreadStatusParams(threadID: "thread-closed-status", status: .init(type: "active")) + ) + #expect(await eventually { + if case .active = chat.status { + return true + } + return false + }) + + try await runtime.transport.emitServerNotification( + method: "thread/closed", + params: ThreadClosedParams(threadID: "thread-closed-status") + ) + + #expect(await eventually { chat.status == .notLoaded && chat.phase == .idle }) + withExtendedLifetime(changes) {} + } + + @Test("live item output deltas accumulate until replacement arrives") + func liveItemOutputDeltasAccumulateUntilReplacementArrives() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-output")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-output")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-output")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-output", + turnID: "turn-output", + item: .init( + id: "command-output", + type: "commandExecution", + command: "echo Hello", + output: "" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-output", + turnID: "turn-output", + itemID: "command-output", + delta: "Hel" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/commandExecution/outputDelta", + params: OutputDeltaParams( + threadID: "thread-output", + turnID: "turn-output", + itemID: "command-output", + delta: "lo" + ) + ) + + #expect(await eventually { + chat.items.first { $0.itemID == "command-output" }?.text == "Hello" + }) + + try await runtime.transport.emitServerNotification( + method: "item/completed", + params: ThreadItemParams( + lifecycle: .completed, + threadID: "thread-output", + turnID: "turn-output", + item: .init( + id: "command-output", + type: "commandExecution", + text: "Completed output", + phase: nil + ) + ) + ) + + #expect(await eventually { + chat.items.first { $0.itemID == "command-output" }?.text == "Completed output" + }) + withExtendedLifetime(changes) {} + } + + @Test("replacement file change updates do not append output") + func replacementFileChangeUpdatesDoNotAppendOutput() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-patch-replacement")) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-patch-replacement")) + + let chat = context.model(for: CodexThreadID(rawValue: "thread-patch-replacement")) + let observation = try await chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + func fileChangePath() -> String? { + guard let item = chat.items.first(where: { $0.itemID == "file-patch" }), + case .fileChange(let fileChange) = item.content + else { + return nil + } + return fileChange.path + } + + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-patch-replacement", + turnID: "turn-patch-replacement", + item: .init( + id: "file-patch", + type: "fileChange", + text: "Initial patch", + path: "Sources/File.swift" + ) + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/fileChange/patchUpdated", + params: FileChangePatchUpdatedParams( + threadID: "thread-patch-replacement", + turnID: "turn-patch-replacement", + itemID: "file-patch", + displayText: "Patch one" + ) + ) + #expect(await eventually { + chat.items.first { $0.itemID == "file-patch" }?.text == "Patch one" + }) + #expect(fileChangePath() == "Sources/File.swift") + + try await runtime.transport.emitServerNotification( + method: "item/fileChange/patchUpdated", + params: FileChangePatchUpdatedParams( + threadID: "thread-patch-replacement", + turnID: "turn-patch-replacement", + itemID: "file-patch", + displayText: "Patch two" + ) + ) + + #expect(await eventually { + chat.items.first { $0.itemID == "file-patch" }?.text == "Patch two" + }) + #expect(fileChangePath() == "Sources/File.swift") + withExtendedLifetime(changes) {} + } + + @Test("chat send revalidates recent fetched results") + func chatSendRevalidatesRecentFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let secondUpdate = Date(timeIntervalSince1970: 2_000) + let completedAt = Date(timeIntervalSince1970: 3_000) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha", updatedAt: firstUpdate), + .init(id: "thread-beta", name: "Beta", updatedAt: secondUpdate), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let alpha = try #require(results.items.first { $0.id.rawValue == "thread-alpha" }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-alpha", status: "running") + let sendTask = Task { + try await alpha.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-alpha", turn: .init( + id: "turn-alpha", + status: "completed", + completedAt: Int(completedAt.timeIntervalSince1970) + )) + ) + + _ = try await sendTask.value + + #expect(alpha.updatedAt == completedAt) + #expect(results.items.map(\.id.rawValue) == ["thread-alpha", "thread-beta"]) + } + + @Test("chat send moves the chat to the front of its workspace") + func chatSendMovesChatToFrontOfWorkspace() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let secondUpdate = Date(timeIntervalSince1970: 2_000) + let completedAt = Date(timeIntervalSince1970: 3_000) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", workspace: workspaceURL, name: "Alpha", updatedAt: firstUpdate), + .init(id: "thread-beta", workspace: workspaceURL, name: "Beta", updatedAt: secondUpdate), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await results.performFetch() + let alpha = try #require(results.items.first { $0.id.rawValue == "thread-alpha" }) + let workspace = try #require(alpha.workspace) + #expect(workspace.chats.map(\.id.rawValue) == ["thread-beta", "thread-alpha"]) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-alpha", status: "running") + let sendTask = Task { + try await alpha.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-alpha", turn: .init( + id: "turn-alpha", + status: "completed", + completedAt: Int(completedAt.timeIntervalSince1970) + )) + ) + + _ = try await sendTask.value + + #expect(workspace.chats.map(\.id.rawValue) == ["thread-alpha", "thread-beta"]) + } + + @Test("chat send refreshes primary recency-sorted fetched results") + func chatSendRefreshesPrimaryRecencySortedFetchedResults() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let secondUpdate = Date(timeIntervalSince1970: 2_000) + let completedAt = Date(timeIntervalSince1970: 3_000) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha", updatedAt: firstUpdate), + .init(id: "thread-beta", name: "Beta", updatedAt: secondUpdate), + ])) + let results = context.fetchedResults(for: CodexFetchDescriptor( + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + try await results.performFetch() + let alpha = try #require(results.items.first { $0.id.rawValue == "thread-alpha" }) + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-alpha", status: "running") + let sendTask = Task { + try await alpha.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-beta", name: "Beta", updatedAt: secondUpdate), + .init(id: "thread-alpha", name: "Alpha", updatedAt: completedAt), + ])) + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-alpha", turn: .init( + id: "turn-alpha", + status: "completed", + completedAt: Int(completedAt.timeIntervalSince1970) + )) + ) + + _ = try await sendTask.value + + #expect(results.items.map(\.id.rawValue) == ["thread-beta", "thread-alpha"]) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 4) + } + + @Test("chat send refreshes incomplete paged results for off-page updates") + func chatSendRefreshesIncompletePagedResultsForOffPageUpdates() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let secondUpdate = Date(timeIntervalSince1970: 2_000) + let completedAt = Date(timeIntervalSince1970: 3_000) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-alpha", name: "Alpha", updatedAt: firstUpdate), + .init(id: "thread-beta", name: "Beta", updatedAt: secondUpdate), + ])) + let allResults = context.fetchedResults(for: CodexFetchDescriptor.recentChats) + try await allResults.performFetch() + let alpha = try #require(allResults.items.first { $0.id.rawValue == "thread-alpha" }) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-beta", name: "Beta", updatedAt: secondUpdate)], + nextCursor: "next" + )) + let pagedResults = context.fetchedResults(for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)], + fetchLimit: 1 + )) + try await pagedResults.performFetch() + + try await runtime.transport.enqueueThreadResume(.init(id: "thread-alpha")) + try await runtime.transport.enqueueTurnStart(turnID: "turn-alpha", status: "running") + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [.init(id: "thread-alpha", name: "Alpha", updatedAt: completedAt)], + nextCursor: "next" + )) + let sendTask = Task { + try await alpha.send("hello") + } + + await runtime.transport.waitForRequest(method: "turn/start") + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TurnCompletedParams(threadID: "thread-alpha", turn: .init( + id: "turn-alpha", + status: "completed", + completedAt: Int(completedAt.timeIntervalSince1970) + )) + ) + + _ = try await sendTask.value + + #expect(pagedResults.items.map(\.id.rawValue) == ["thread-alpha"]) + } + + @Test("workspace starts new chats through its model context") + func workspaceStartsNewChatThroughContext() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-new", model: "gpt-5") + + let chat = try await workspace.startChat(.init(options: .init(model: "gpt-5"))) + + #expect(chat.id == "thread-new") + #expect(chat.workspace === workspace) + #expect(workspace.chats.first === chat) + + let request = try #require( + await runtime.transport.recordedRequests(method: "thread/start").first) + let params = try request.decodeParams(ThreadStartParams.self) + #expect(params.cwd == workspaceURL.path) + #expect(params.model == "gpt-5") + } + + @Test("model context starts reviews and inserts the active review chat into fetched results") + func modelContextStartsReviewAndInsertsActiveChatIntoFetchedResults() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, + threads: [ + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + modelProvider: "openai", + recencyAt: Date(timeIntervalSince1970: 1_000) + ), + ], + nextCursor: "server-next" + )) + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart(turnID: "turn-review", reviewThreadID: "thread-review") + let results = context.fetchedResults( + for: CodexFetchDescriptor( + predicate: sourceKindEqualityChatPredicate(.cli), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + try await results.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-review", + workspace: workspaceURL, + name: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.", + recencyAt: Date(timeIntervalSince1970: 2_000) + ), + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + modelProvider: "openai", + recencyAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + + #expect(results.items.first === started.chat) + #expect(results.items.map(\.id.rawValue) == ["thread-review", "thread-existing"]) + #expect(started.chat.id == started.session.activeTurnThreadID) + #expect(started.chat.workspace?.url.path == workspaceURL.path) + #expect(started.chat.preview == "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.") + #expect(started.chat.title == started.chat.preview) + + let requests = await runtime.transport.recordedRequests().map(\.method) + #expect(requests.contains("thread/start")) + #expect(requests.contains("review/start")) + #expect(requests.filter { $0 == "thread/list" }.count == 2) + } + + @Test("started review seed does not truncate an existing chat transcript") + func startedReviewSeedDoesNotTruncateExistingChatTranscript() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let existingChat = context.model(for: CodexThreadID(rawValue: "thread-review")) + let existingSnapshot = CodexThreadSnapshot( + id: "thread-review", + workspace: workspaceURL, + turns: [ + .init( + id: "turn-existing-user", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "existing-user-message", + kind: .userMessage, + content: .message(.init( + id: "existing-user-message", + role: .user, + text: "previous request" + )) + ), + ] + ), + .init( + id: "turn-existing-agent", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "existing-agent-message", + kind: .agentMessage, + content: .message(.init( + id: "existing-agent-message", + role: .assistant, + text: "previous response" + )) + ), + ] + ), + ] + ) + existingChat.apply( + existingSnapshot, + workspace: nil + ) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + .init( + id: "turn-seed", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ), + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + + #expect(started.chat === existingChat) + #expect(started.chat.turns.map(\.id.rawValue) == [ + "turn-existing-user", + "turn-existing-agent", + "turn-seed", + ]) + #expect(started.chat.items.map(\.itemID) == [ + "existing-user-message", + "existing-agent-message", + "turn-seed", + ]) + #expect(started.chat.items.map(\.text) == [ + "previous request", + "previous response", + "current changes", + ]) + } + + @Test("model actor review start multicasts the active review to the main context") + func modelActorReviewStartMulticastsActiveReviewToMainContext() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let container = CodexModelContainer(appServer: runtime.server) + let mainContext = container.mainContext + let actor = TestCodexModelActor(modelContainer: container) + try await runtime.transport.enqueueUserVisibleThreadList( + CodexAppServerTestThreadPage(threads: []) + ) + let results = mainContext.fetchedResults( + for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + try await results.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init(id: "thread-review", workspace: workspaceURL, name: "Review") + ])) + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "thread/resume should not be needed for a just-started review", + for: "thread/resume" + ) + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-review", + kind: .userMessage, + content: .message(.init( + id: "turn-review", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let reviewChatID = try await actor.startReviewID( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + + let mainChat = try #require(mainContext.registeredModel(for: reviewChatID)) + #expect(results.items.first === mainChat) + #expect(results.items.map(\.id.rawValue) == ["thread-review"]) + #expect(mainChat.workspace?.url.path == workspaceURL.path) + #expect(mainChat.items.map(\.text) == ["current changes"]) + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "rollout is empty", + for: "thread/turns/list" + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "includeTurns is unavailable before first user message", + for: "thread/read" + ) + let observation = try await mainChat.observe() + defer { + observation.cancel() + } + #expect(mainChat.items.map(\.text) == ["current changes"]) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").isEmpty) + } + + @Test("started review chat survives temporary thread list omission") + func startedReviewChatSurvivesTemporaryThreadListOmission() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + recencyAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + let results = context.fetchedResults( + for: CodexFetchDescriptor( + predicate: archivedChatPredicate(false), + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] + )) + try await results.performFetch() + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + recencyAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + let reviewChat = started.chat + let workspace = try #require(reviewChat.workspace) + + try await runtime.transport.enqueueUserVisibleThreadList(.init(profile: .currentV2, threads: [ + .init( + id: "thread-existing", + workspace: workspaceURL, + name: "Existing", + recencyAt: Date(timeIntervalSince1970: 1_000) + ), + ])) + + try await results.performFetch() + + #expect(results.items.contains { $0 === reviewChat }) + #expect(results.items.map(\.id.rawValue) == ["thread-review", "thread-existing"]) + #expect(workspace.chats.contains { $0 === reviewChat }) + #expect(reviewChat.modelContext === context) + #expect(context.registeredModel(for: reviewChat.id) === reviewChat) + #expect(await runtime.transport.recordedRequests(method: "thread/list").count == 6) + } + + @Test("started review observation reuses the live event thread without resuming") + func startedReviewObservationReusesLiveEventThreadWithoutResuming() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "thread/resume should not be needed for a just-started review", + for: "thread/resume" + ) + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + items: [ + .init( + id: "turn-review", + kind: .enteredReviewMode, + content: .log("Review started") + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-review", + workspace: workspaceURL, + name: "Review", + modelProvider: "openai" + )) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + #expect(started.chat.items.map(\.text) == ["Review started"]) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/turns/list").count == 1) + withExtendedLifetime(changes) {} + } + + @Test("started review consumes its prepared event thread across refresh before observation") + func startedReviewConsumesPreparedEventThreadAcrossRefreshBeforeObservation() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueThreadResume(.init( + id: "thread-review", + workspace: workspaceURL + )) + for text in ["Review started", "Review still running"] { + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + items: [ + .init( + id: "turn-review", + kind: .enteredReviewMode, + content: .log(text) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-review", + workspace: workspaceURL, + name: "Review", + modelProvider: "openai" + )) + } + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + try await context.refresh(started.chat) + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + + #expect(started.chat.items.map(\.text) == ["Review still running"]) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + #expect(await runtime.transport.recordedRequests(method: "thread/turns/list").count == 2) + } + + @Test("started review observation survives empty rollout history reads") + func startedReviewObservationSurvivesEmptyRolloutHistoryReads() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + await runtime.transport.enqueueFailure( + code: -32_000, + message: "thread/resume should not be needed for a just-started review", + for: "thread/resume" + ) + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-review", + kind: .userMessage, + content: .message(.init( + id: "turn-review", + role: .user, + text: "current changes" + )) + ), + ] + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "rollout is empty", + for: "thread/turns/list" + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "includeTurns is unavailable before first user message", + for: "thread/read" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + #expect(started.chat.items.map(\.text) == ["current changes"]) + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + + #expect(started.chat.items.map(\.text) == ["current changes"]) + #expect(started.chat.workspace?.url.path == workspaceURL.path) + #expect(await runtime.transport.recordedRequests(method: "thread/resume").isEmpty) + #expect(await runtime.transport.recordedRequests(method: "thread/turns/list").count == 1) + #expect(await runtime.transport.recordedRequests(method: "thread/read").count == 1) + } + + @Test("started review observation replays prepared thread events received before observe") + func startedReviewObservationReplaysPreparedThreadEventsReceivedBeforeObserve() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-review", + kind: .userMessage, + content: .message(.init( + id: "turn-review", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + try await emitAgentMessageStarted( + on: runtime.transport, + threadID: "thread-review", + turnID: "turn-review", + itemID: "message-before-observe", + phase: "final_answer" + ) + try await runtime.transport.emitServerNotification( + method: "item/agentMessage/delta", + params: TurnDeltaParams( + threadID: "thread-review", + turnID: "turn-review", + itemID: "message-before-observe", + delta: "Buffered before observe", + phase: "final_answer" + ) + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "rollout is empty", + for: "thread/turns/list" + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "includeTurns is unavailable before first user message", + for: "thread/read" + ) + + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + #expect(await eventually { + started.chat.items.first { $0.itemID == "message-before-observe" }?.text + == "Buffered before observe" + }) + withExtendedLifetime(changes) {} + } + + @Test("started review observation skips prepared thread history covered by refresh") + func startedReviewObservationSkipsPreparedThreadHistoryCoveredByRefresh() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-review", + kind: .userMessage, + content: .message(.init( + id: "turn-review", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-review", + turnID: "turn-review" + ) + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "final-message", + kind: .agentMessage, + content: .message(.init( + id: "final-message", + role: .assistant, + phase: .finalAnswer, + text: "Done" + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init( + id: "thread-review", + workspace: workspaceURL, + status: .idle + )) + + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try? await Task.sleep(for: .milliseconds(100)) + + #expect(started.chat.turn(id: "turn-review")?.status == .completed) + #expect(started.chat.phase == .terminal( + turnID: "turn-review", + disposition: .completed + )) + #expect(started.chat.items.map(\.itemID) == ["final-message"]) + withExtendedLifetime(changes) {} + } + + @Test("started review ignores advisory subturn start after empty history read") + func startedReviewIgnoresAdvisorySubturnStartAfterEmptyHistoryRead() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "rollout is empty", + for: "thread/turns/list" + ) + await runtime.transport.enqueueFailure( + code: -32_000, + message: "includeTurns is unavailable before first user message", + for: "thread/read" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + #expect(started.chat.turns.map(\.id.rawValue) == ["turn-seed"]) + #expect(started.chat.items.map(\.text) == ["current changes"]) + + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + let changes = ChatUpdateRecorder(stream: observation.updates) + + try await runtime.transport.emitServerNotification( + method: "turn/started", + params: TurnStartedParams( + threadID: "thread-review", + turnID: "turn-live" + ) + ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: "thread-review", + turnID: "turn-seed", + item: .init( + id: "review-mode", + type: "enteredReviewMode", + text: "current changes" + ) + ) + ) + + #expect(await eventually { + started.chat.turns.map(\.id.rawValue) == ["turn-seed"] + && started.chat.items.map(\.itemID) == ["turn-seed", "review-mode"] + && started.chat.items.map(\.text) == ["current changes", "current changes"] + }) + withExtendedLifetime(changes) {} + } + + @Test("started review snapshot merge replaces provisional seed with authoritative review turn") + func startedReviewSnapshotMergeReplacesProvisionalSeedWithAuthoritativeReviewTurn() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + #expect(started.chat.turns.map(\.id.rawValue) == ["turn-seed"]) + #expect(started.chat.items.map(\.text) == ["current changes"]) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-live", + state: .inProgress, + items: [ + .init( + id: "review-mode", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "command-1", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc", + status: .inProgress + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + #expect(started.chat.turns.map(\.id.rawValue) == ["turn-live"]) + #expect(started.chat.items.map(\.itemID) == ["review-mode", "command-1"]) + #expect(started.chat.items.map(\.text) == ["current changes", "/bin/zsh -lc"]) + } + + @Test("started review observation replaces response seed with authoritative turn list when available") + func startedReviewObservationReplacesResponseSeedWithAuthoritativeTurnListWhenAvailable() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-review", + kind: .userMessage, + content: .message(.init( + id: "turn-review", + role: .user, + text: "current changes" + )) + ), + ] + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + items: [ + .init( + id: "turn-review", + kind: .enteredReviewMode, + content: .log("Review started from live turn list") + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-review", workspace: workspaceURL)) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + + #expect(started.chat.items.map(\.text) == ["Review started from live turn list"]) + } + + @Test("started review observation drops not-loaded response seed when full turn items arrive") + func startedReviewObservationDropsNotLoadedSeedWhenFullTurnItemsArrive() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .notLoaded, + items: [ + .init( + id: "seed-review", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "review-mode", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "command-1", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc", + status: .inProgress + )) + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-review", workspace: workspaceURL)) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + #expect(started.chat.items.map(\.itemID) == ["seed-review"]) + + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + + #expect(started.chat.items.map(\.itemID) == ["review-mode", "command-1"]) + #expect(started.chat.items.map(\.text) == ["current changes", "/bin/zsh -lc"]) + } + + @Test("started review refresh replaces a marker when its raw identity changes") + func startedReviewRefreshReplacesMarkerWhenRawIdentityChanges() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "turn-review", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "review-mode", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + ])) + try await runtime.transport.enqueueThreadRead(.init(id: "thread-review", workspace: workspaceURL)) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + let seededItem = try #require(started.chat.items.first) + #expect(seededItem.itemID == "turn-review") + #expect(seededItem.id.rawValue == "turn-review:enteredReviewMode:turn-review") + + try await context.refresh(started.chat) + + let reviewMarkers = started.chat.items.filter { $0.kind == .enteredReviewMode } + let refreshedItem = try #require(reviewMarkers.first) + #expect(reviewMarkers.count == 1) + #expect(refreshedItem !== seededItem) + #expect(refreshedItem.itemID == "review-mode") + #expect(refreshedItem.id.rawValue == "turn-review:enteredReviewMode:review-mode") + } + + @Test("started review refresh coalesces running command snapshot replay") + func startedReviewRefreshCoalescesRunningCommandSnapshotReplay() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let startedAt = Date(timeIntervalSince1970: 10) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + started.chat.apply(.itemStarted( + .init( + id: "live-command", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .inProgress, + startedAt: startedAt, + processID: "123", + source: .agent + )) + ), + turnID: "turn-review" + )) + let seededCommand = try #require( + started.chat.items.first { $0.kind == .commandExecution } + ) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "snapshot-command", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + status: .inProgress + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + let commandItems = started.chat.items.filter { $0.kind == .commandExecution } + let commandItem = try #require(commandItems.first) + let command: CodexCommand + switch commandItem.content { + case .command(let value): + command = value + default: + Issue.record("Expected a command item.") + return + } + #expect(commandItems.count == 1) + #expect(commandItem === seededCommand) + #expect(commandItem.itemID == "snapshot-command") + #expect(command.cwd == workspaceURL.path) + #expect(command.startedAt == startedAt) + #expect(command.processID == "123") + #expect(command.source == .agent) + } + + @Test("started review refresh moves running command replay into authoritative turn") + func startedReviewRefreshMovesRunningCommandReplayIntoAuthoritativeTurn() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let startedAt = Date(timeIntervalSince1970: 10) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + _ = started.chat.apply(.itemStarted( + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .inProgress, + startedAt: startedAt, + processID: "123", + source: .agent + )) + ), + turnID: "turn-seed" + )) + _ = started.chat.apply(.turnStarted("turn-live")) + let liveCommand = try #require( + started.chat.items.first { $0.kind == .commandExecution } + ) + #expect(liveCommand.turnID?.rawValue == "turn-seed") + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-live", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .inProgress, + processID: "123", + source: .agent + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + let commandItems = started.chat.items.filter { $0.kind == .commandExecution } + let commandItem = try #require(commandItems.first) + let command: CodexCommand + switch commandItem.content { + case .command(let value): + command = value + default: + Issue.record("Expected a command item.") + return + } + #expect(commandItems.count == 1) + #expect(commandItem !== liveCommand) + #expect(liveCommand.modelContext == nil) + #expect(liveCommand.turnID == nil) + #expect(commandItem.turnID?.rawValue == "turn-live") + #expect(commandItem.itemID == "call-live") + #expect(commandItem.id.rawValue == "turn-live:commandExecution:call-live") + #expect(started.chat.items(in: "turn-seed").contains { $0.kind == .commandExecution } == false) + #expect(started.chat.items(in: "turn-live").filter { $0.kind == .commandExecution }.count == 1) + #expect(command.startedAt == startedAt) + #expect(command.processID == "123") + #expect(command.source == .agent) + } + + @Test("started review sparse terminal refresh preserves live command log items") + func startedReviewSparseTerminalRefreshPreservesLiveCommandLogItems() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-review")) + _ = started.chat.apply(.itemStarted( + .init( + id: "review-start", + kind: .enteredReviewMode, + content: .log("Review started.") + ), + turnID: "turn-review" + )) + _ = started.chat.apply(.itemStarted( + .init( + id: "command-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + output: " M Package.swift", + status: .completed, + source: .agent + )) + ), + turnID: "turn-review" + )) + _ = started.chat.apply(.terminal(.completed(.init( + turnID: "turn-review", + completedAt: Date(timeIntervalSince1970: 10) + )))) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .idle, + turns: [ + .init( + id: "turn-review", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: started.chat.workspace + ) + + #expect(started.chat.items(in: "turn-review").contains { $0.itemID == "command-live" }) + #expect(started.chat.items(in: "turn-review").contains { $0.kind == .exitedReviewMode }) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .idle, + turns: [] + ), + workspace: started.chat.workspace + ) + + #expect(started.chat.turn(id: "turn-review") == nil) + #expect(started.chat.items(in: "turn-review").isEmpty) + } + + @Test("started review refresh folds synthesized rollout turns into the live turn") + func startedReviewRefreshFoldsSynthesizedRolloutTurnsIntoLiveTurn() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let startedAt = Date(timeIntervalSince1970: 10) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + _ = started.chat.apply(.itemStarted( + .init( + id: "reasoning-live", + kind: .reasoning, + content: .reasoning(.init(summary: "Reviewing differences")) + ), + turnID: "turn-seed" + )) + _ = started.chat.apply(.itemStarted( + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .inProgress, + startedAt: startedAt, + processID: "123", + source: .agent + )) + ), + turnID: "turn-seed" + )) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "turn-rollout", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "user-real", + kind: .userMessage, + content: .message(.init( + id: "user-real", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reasoning-live", + kind: .reasoning, + content: .reasoning(.init(summary: "Reviewing differences")) + ), + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .inProgress, + processID: "123", + source: .agent + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + #expect(started.chat.turns.contains { $0.id == "turn-rollout" } == false) + #expect(started.chat.items.filter { $0.kind == .userMessage }.count == 1) + #expect(started.chat.items.filter { $0.kind == .reasoning }.count == 1) + let commandItems = started.chat.items.filter { $0.kind == .commandExecution } + #expect(commandItems.count == 1) + #expect(commandItems.first?.turnID?.rawValue == "turn-seed") + + _ = started.chat.apply(.itemCompleted( + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git status --short'", + cwd: workspaceURL.path, + status: .completed, + startedAt: startedAt, + processID: "123", + source: .agent + )) + ), + turnID: "turn-seed" + )) + let completedCommand = try #require( + started.chat.items.first { $0.kind == .commandExecution } + ) + guard case .command(let completedValue) = completedCommand.content else { + Issue.record("Expected a command item.") + return + } + #expect(completedValue.status == .completed) + } + + @Test("started review adopts rollout records with fully synthesized identities") + func startedReviewAdoptsRolloutRecordsWithFullySynthesizedIdentities() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let startedAt = Date(timeIntervalSince1970: 10) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + + // Early refresh: the rollout materializes the running review turn + // under a synthesized turn id whose only item is the index-named user + // message — no identity is shared with the seeded turn. + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-read-1", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes" + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + #expect(started.chat.turns.map(\.id) == ["turn-seed"]) + #expect(started.chat.items.filter { $0.kind == .userMessage }.count == 1) + + _ = started.chat.apply(.itemStarted( + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'swift test'", + cwd: workspaceURL.path, + status: .inProgress, + startedAt: startedAt, + processID: "42", + source: .agent + )) + ), + turnID: "turn-seed" + )) + + // The next read regenerates the synthesized turn id. + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-read-2", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "item-2", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'swift test'", + cwd: workspaceURL.path, + status: .inProgress, + processID: "42", + source: .agent + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + #expect(started.chat.turns.map(\.id) == ["turn-seed"]) + #expect(started.chat.items.filter { $0.kind == .userMessage }.count == 1) + #expect(started.chat.items.filter { $0.kind == .commandExecution }.count == 1) + + _ = started.chat.apply(.itemCompleted( + .init( + id: "call-live", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'swift test'", + cwd: workspaceURL.path, + status: .completed, + startedAt: startedAt, + processID: "42", + source: .agent + )) + ), + turnID: "turn-seed" + )) + let liveCommand = try #require( + started.chat.items.first { $0.kind == .commandExecution } + ) + guard case .command(let liveValue) = liveCommand.content else { + Issue.record("Expected a command item.") + return + } + #expect(liveValue.status == .completed) + + // Once the rollout materializes a terminal reviewer turn, its + // authoritative boundary must not be folded back into the still-open + // seed merely because its synthesized identity has not been seen. + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-exit", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "item-2", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + .init( + id: "rollout-activity", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "call-after-exit", + kind: .commandExecution, + content: .command(.init( + command: "/bin/zsh -lc 'git diff --check'", + cwd: workspaceURL.path, + exitCode: 0, + status: .completed, + source: .agent + )) + ), + ] + ), + .init( + id: "rollout-reviewer", + state: .interrupted, + itemsLoadState: .full, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "item-2", + kind: .userMessage, + content: .message(.init( + id: "item-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "item-3", + kind: .agentMessage, + content: .message(.init( + id: "item-3", + role: .assistant, + text: "No issues found." + )) + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + #expect( + started.chat.turns.map(\.id) + == ["turn-seed", "rollout-activity", "rollout-reviewer"] + ) + #expect(started.chat.items(in: "rollout-reviewer").count == 3) + let reviewerMessage = try #require( + started.chat.items(in: "rollout-reviewer").first { $0.kind == .agentMessage } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("started review classifies a unique-id live assistant after exit activity") + func startedReviewClassifiesUniqueIDLiveAssistantAfterExitActivity() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + _ = started.chat.apply(.itemCompleted( + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + turnID: "turn-seed" + )) + _ = started.chat.apply(.itemCompleted( + .init( + id: "review-command", + kind: .commandExecution, + content: .command(.init(command: "/bin/zsh -lc")) + ), + turnID: "turn-seed" + )) + _ = started.chat.apply(.itemCompleted( + .init( + id: "msg-unique", + kind: .agentMessage, + content: .message(.init( + id: "msg-unique", + role: .assistant, + text: "No issues found." + )) + ), + turnID: "turn-seed" + )) + + let reviewerMessage = try #require( + started.chat.items(in: "turn-seed").first { $0.itemID == "msg-unique" } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("coalesced review companion waits for a full snapshot") + func coalescedReviewCompanionWaitsForFullSnapshot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "review-chat", modelContext: context) + let items: [CodexThreadItem] = [ + .init( + id: "review-entry", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "review-command", + kind: .commandExecution, + content: .command(.init(command: "/bin/zsh -lc")) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + + let summaryChanges = chat.apply(.snapshot(.init( + id: "coalesced-review", + state: .inProgress, + itemsLoadState: .summary, + items: items + ))) + + let summaryMessage = try #require( + chat.items(in: "coalesced-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(summaryMessage.itemsLoadState == .summary) + #expect(summaryMessage.origin == .currentV2Item) + #expect(summaryMessage.semanticRelation == nil) + let summaryUpdates = chat.observationUpdates(for: summaryChanges) + #expect(summaryUpdates.contains { update in + guard case .turnInserted(let turn, _) = update else { + return false + } + return turn.items.contains { + $0.id == "reviewer-assistant" + && $0.semanticRelation == nil + } + }) + + let fullChanges = chat.apply(.snapshot(.init( + id: "coalesced-review", + state: .inProgress, + itemsLoadState: .full, + items: items + ))) + + let fullMessage = try #require( + chat.items(in: "coalesced-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(fullMessage.itemsLoadState == .full) + #expect(fullMessage.origin == .reviewRolloutAssistant) + #expect(fullMessage.semanticRelation == .companionOf(.exitedReviewMode)) + let fullUpdates = chat.observationUpdates(for: fullChanges) + #expect(fullUpdates.contains { update in + guard case .itemUpdated(let item, let turnID, _) = update else { + return false + } + return item.id == "reviewer-assistant" + && turnID == "coalesced-review" + && item.origin == .reviewRolloutAssistant + && item.semanticRelation == .companionOf(.exitedReviewMode) + }) + } + + @Test("adjacent review companion waits for a full snapshot") + func adjacentReviewCompanionWaitsForFullSnapshot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "adjacent-review-chat", modelContext: context) + let items: [CodexThreadItem] = [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + + _ = chat.apply(.snapshot(.init( + id: "adjacent-review", + state: .inProgress, + itemsLoadState: .summary, + items: items + ))) + + let summaryMessage = try #require( + chat.items(in: "adjacent-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(summaryMessage.itemsLoadState == .summary) + #expect(summaryMessage.origin == .currentV2Item) + #expect(summaryMessage.semanticRelation == nil) + + let fullChanges = chat.apply(.snapshot(.init( + id: "adjacent-review", + state: .inProgress, + itemsLoadState: .full, + items: items + ))) + + let fullMessage = try #require( + chat.items(in: "adjacent-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(fullMessage.itemsLoadState == .full) + #expect(fullMessage.origin == .reviewRolloutAssistant) + #expect(fullMessage.semanticRelation == .companionOf(.exitedReviewMode)) + let fullUpdates = chat.observationUpdates(for: fullChanges) + #expect(fullUpdates.contains { update in + guard case .itemUpdated(let item, let turnID, _) = update else { + return false + } + return item.id == "reviewer-assistant" + && turnID == "adjacent-review" + && item.origin == .reviewRolloutAssistant + && item.semanticRelation == .companionOf(.exitedReviewMode) + }) + } + + @Test("full snapshot normalization ignores stale omitted review items") + func fullSnapshotNormalizationIgnoresStaleOmittedReviewItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "corrected-review-chat", modelContext: context) + let assistant = CodexThreadItem( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "Ordinary assistant response" + )) + ) + + _ = chat.apply(.snapshot(.init( + id: "corrected-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + assistant, + ] + ))) + + let classifiedMessage = try #require( + chat.items(in: "corrected-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(classifiedMessage.origin == .reviewRolloutAssistant) + #expect(classifiedMessage.semanticRelation == .companionOf(.exitedReviewMode)) + + let correctedChanges = chat.apply(.snapshot(.init( + id: "corrected-review", + state: .inProgress, + itemsLoadState: .full, + items: [assistant] + ))) + + let correctedMessage = try #require( + chat.items(in: "corrected-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(correctedMessage.origin == .currentV2Item) + #expect(correctedMessage.semanticRelation == nil) + #expect(chat.items(in: "corrected-review").contains { + $0.itemID == "review-exit" + } == false) + let correctedUpdates = chat.observationUpdates(for: correctedChanges) + #expect(correctedUpdates.contains { update in + guard case .itemRemoved(let locator) = update else { + return false + } + return locator.id == "review-exit" + && locator.turnID == "corrected-review" + }) + #expect(correctedUpdates.contains { update in + guard case .itemUpdated(let item, let turnID, _) = update else { + return false + } + return item.id == "reviewer-assistant" + && turnID == "corrected-review" + && item.origin == .currentV2Item + && item.semanticRelation == nil + }) + + _ = chat.apply(.itemUpdated( + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "Updated ordinary assistant response" + )) + ), + turnID: "corrected-review" + )) + + let liveUpdatedMessage = try #require( + chat.items(in: "corrected-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(liveUpdatedMessage.origin == .currentV2Item) + #expect(liveUpdatedMessage.semanticRelation == nil) + } + + @Test("first snapshot classifies a persisted companion after a review exit") + func firstSnapshotClassifiesPersistedCompanionAfterReviewExit() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "persisted-review-chat", modelContext: context) + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "review-boundary", + state: .completed, + items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: nil + ) + + _ = chat.apply(.snapshot(.init( + id: "persisted-companion", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + ))) + + let reviewerMessage = try #require( + chat.items(in: "persisted-companion").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("partial snapshot uses the loaded preceding review boundary") + func partialSnapshotUsesLoadedPrecedingReviewBoundary() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "partial-review-chat", modelContext: context) + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "review-boundary", + state: .completed, + items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: nil + ) + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "persisted-companion", + state: .completed, + items: [ + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + ), + ] + ), + workspace: nil, + preservesExistingTurnItems: true + ) + + let reviewerMessage = try #require( + chat.items(in: "persisted-companion").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("authoritative snapshot does not use omitted loaded review boundaries") + func authoritativeSnapshotDoesNotUseOmittedLoadedReviewBoundaries() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "authoritative-review-chat", modelContext: context) + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "stale-review-boundary", + state: .completed, + items: [ + .init( + id: "stale-review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: nil + ) + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "ordinary-persisted-turn", + state: .completed, + items: [ + .init( + id: "ordinary-user-1", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-1", + role: .user, + text: "repeated prompt" + )) + ), + .init( + id: "ordinary-user-2", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-2", + role: .user, + text: "repeated prompt" + )) + ), + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: "Ordinary response" + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let assistant = try #require( + chat.items(in: "ordinary-persisted-turn").first { + $0.itemID == "ordinary-assistant" + } + ) + #expect(assistant.origin == .currentV2Item) + #expect(assistant.semanticRelation == nil) + #expect(chat.turns.map(\.id) == ["ordinary-persisted-turn"]) + } + + @Test("summary record preserves its loaded full review boundary") + func summaryRecordPreservesLoadedFullReviewBoundary() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "summarized-review-chat", modelContext: context) + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "review-boundary", + state: .completed, + items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: nil + ) + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "review-boundary", + state: .completed, + itemsLoadState: .summary + ), + .init( + id: "persisted-companion", + state: .completed, + items: [ + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let reviewerMessage = try #require( + chat.items(in: "persisted-companion").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + #expect(chat.items(in: "review-boundary").contains { + $0.itemID == "review-exit" + }) + } + + @Test("summary record cannot establish a preceding review boundary") + func summaryRecordCannotEstablishPrecedingReviewBoundary() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "partial-review-boundary-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + turns: [ + .init( + id: "partial-boundary", + state: .completed, + itemsLoadState: .summary, + items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + .init( + id: "ordinary-turn", + state: .completed, + items: [ + .init( + id: "user-1", + kind: .userMessage, + content: .message(.init( + id: "user-1", + role: .user, + text: "repeated prompt" + )) + ), + .init( + id: "user-2", + kind: .userMessage, + content: .message(.init( + id: "user-2", + role: .user, + text: "repeated prompt" + )) + ), + .init( + id: "assistant", + kind: .agentMessage, + content: .message(.init( + id: "assistant", + role: .assistant, + text: "Ordinary response" + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let assistant = try #require( + chat.items(in: "ordinary-turn").first { $0.itemID == "assistant" } + ) + #expect(assistant.origin == .currentV2Item) + #expect(assistant.semanticRelation == nil) + } + + @Test("ordered update uses narrative evidence before the existing item") + func orderedUpdateUsesNarrativeEvidenceBeforeExistingItem() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "ordered-review-chat", modelContext: context) + + _ = chat.apply(.itemCompleted( + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: "Ordinary response" + )) + ), + turnID: "ordered-review" + )) + _ = chat.apply(.itemCompleted( + .init( + id: "later-review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + turnID: "ordered-review" + )) + _ = chat.apply(.itemUpdated( + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: "Updated ordinary response" + )) + ), + turnID: "ordered-review" + )) + + let assistant = try #require( + chat.items(in: "ordered-review").first { + $0.itemID == "ordinary-assistant" + } + ) + #expect(assistant.origin == .currentV2Item) + #expect(assistant.semanticRelation == nil) + } + + @Test("terminal transcript provides full review companion evidence") + func terminalTranscriptProvidesFullReviewCompanionEvidence() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "terminal-review-chat", modelContext: context) + _ = chat.apply(.turnStarted("terminal-review")) + + let terminalChanges = chat.apply(.terminal(.completed(.init( + turnID: "terminal-review", + transcript: .init(items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ]), + transcriptItemsLoadState: .full + )))) + + let turn = try #require(chat.turn(id: "terminal-review")) + #expect(turn.itemsLoadState == .full) + let reviewerMessage = try #require( + chat.items(in: "terminal-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(reviewerMessage.origin == .reviewRolloutAssistant) + #expect(reviewerMessage.semanticRelation == .companionOf(.exitedReviewMode)) + let terminalUpdates = chat.observationUpdates(for: terminalChanges) + #expect(terminalUpdates.contains { update in + guard case .itemInserted(let item, let turnID, _) = update else { + return false + } + return item.id == "reviewer-assistant" + && turnID == "terminal-review" + && item.semanticRelation == .companionOf(.exitedReviewMode) + }) + } + + @Test("summary terminal transcript does not provide full companion evidence") + func summaryTerminalTranscriptDoesNotProvideFullCompanionEvidence() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "summary-terminal-review-chat", modelContext: context) + _ = chat.apply(.turnStarted("summary-terminal-review")) + + _ = chat.apply(.terminal(.completed(.init( + turnID: "summary-terminal-review", + transcript: .init(items: [ + .init( + id: "review-exit", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ]), + transcriptItemsLoadState: .summary + )))) + + let turn = try #require(chat.turn(id: "summary-terminal-review")) + #expect(turn.itemsLoadState == .summary) + let reviewerMessage = try #require( + chat.items(in: "summary-terminal-review").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(reviewerMessage.origin == .currentV2Item) + #expect(reviewerMessage.semanticRelation == nil) + } + + @Test("summary terminal transcript preserves an existing full turn") + func summaryTerminalTranscriptPreservesExistingFullTurn() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "full-terminal-chat", modelContext: context) + let fullItem = CodexThreadItem( + id: "assistant", + kind: .agentMessage, + content: .message(.init( + id: "assistant", + role: .assistant, + text: "Complete response" + )) + ) + _ = chat.apply(.snapshot(.init( + id: "turn", + state: .inProgress, + itemsLoadState: .full, + items: [fullItem] + ))) + + _ = chat.apply(.terminal(.completed(.init( + turnID: "turn", + transcript: .init(items: [ + .init( + id: "assistant", + kind: .agentMessage, + content: .message(.init( + id: "assistant", + role: .assistant, + text: "Summary response" + )) + ), + ]), + transcriptItemsLoadState: .summary + )))) + + let turn = try #require(chat.turn(id: "turn")) + #expect(turn.itemsLoadState == .full) + let assistant = try #require( + chat.items(in: "turn").first { $0.itemID == "assistant" } + ) + #expect(assistant.text == "Complete response") + #expect(assistant.itemsLoadState == .full) + } + + @Test("full terminal transcript removes omitted live items") + func fullTerminalTranscriptRemovesOmittedLiveItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "authoritative-terminal-chat", modelContext: context) + _ = chat.apply(.itemCompleted( + .init( + id: "omitted", + kind: .agentMessage, + content: .message(.init( + id: "omitted", + role: .assistant, + text: "Omitted live item" + )) + ), + turnID: "turn" + )) + let retainedItem = CodexThreadItem( + id: "retained", + kind: .agentMessage, + content: .message(.init( + id: "retained", + role: .assistant, + text: "Retained terminal item" + )) + ) + + let changes = chat.apply(.terminal(.completed(.init( + turnID: "turn", + transcript: .init(items: [retainedItem]), + transcriptItemsLoadState: .full + )))) + + #expect(chat.items(in: "turn").map(\.itemID) == ["retained"]) + let updates = chat.observationUpdates(for: changes) + #expect(updates.contains { update in + guard case .itemRemoved(let locator) = update else { + return false + } + return locator.id == "omitted" && locator.turnID == "turn" + }) + } + + @Test("live persisted review companion waits for a full completion snapshot") + func livePersistedReviewCompanionWaitsForFullCompletionSnapshot() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "review-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .subAgentReview, + turns: [ + .init( + id: "review-boundary", + state: .completed, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + ] + ), + workspace: nil + ) + _ = chat.apply(.turnStarted("reviewer-turn")) + for id in ["reviewer-user-1", "reviewer-user-2"] { + _ = chat.apply(.itemCompleted( + .init( + id: id, + kind: .userMessage, + content: .message(.init( + id: id, + role: .user, + text: "current changes" + )) + ), + turnID: "reviewer-turn" + )) + } + _ = chat.apply(.itemCompleted( + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + turnID: "reviewer-turn" + )) + + let liveMessage = try #require( + chat.items(in: "reviewer-turn").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(liveMessage.origin == .currentV2Item) + #expect(liveMessage.semanticRelation == nil) + + _ = chat.apply(.terminal(.completed(.init(turnID: "reviewer-turn")))) + + let sparseCompletedMessage = try #require( + chat.items(in: "reviewer-turn").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(sparseCompletedMessage.origin == .currentV2Item) + #expect(sparseCompletedMessage.semanticRelation == nil) + + _ = chat.apply(.snapshot(.init( + id: "reviewer-turn", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "reviewer-user-1", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-user-2", + kind: .userMessage, + content: .message(.init( + id: "reviewer-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "reviewer-assistant", + kind: .agentMessage, + content: .message(.init( + id: "reviewer-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + ))) + + let completedMessage = try #require( + chat.items(in: "reviewer-turn").first { + $0.itemID == "reviewer-assistant" + } + ) + #expect(completedMessage.origin == .reviewRolloutAssistant) + #expect(completedMessage.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("persisted review companion does not depend on the thread source") + func persistedReviewCompanionDoesNotDependOnThreadSource() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "legacy-review-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .vscode, + turns: [ + .init( + id: "prior-review", + state: .completed, + items: [ + .init( + id: "prior-review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + .init( + id: "ordinary-turn", + state: .completed, + items: [ + .init( + id: "ordinary-user-1", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-1", + role: .user, + text: "Repeat this prompt." + )) + ), + .init( + id: "ordinary-user-2", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-2", + role: .user, + text: "Repeat this prompt." + )) + ), + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: "This is an ordinary response." + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let response = try #require( + chat.items(in: "ordinary-turn").first { $0.itemID == "ordinary-assistant" } + ) + #expect(response.origin == .reviewRolloutAssistant) + #expect(response.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("ordinary chat does not classify a duplicate prompt without a review exit") + func ordinaryChatDoesNotClassifyDuplicatePromptWithoutReviewExit() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "ordinary-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .appServer, + turns: [ + .init( + id: "ordinary-turn", + state: .completed, + items: [ + .init( + id: "ordinary-user-1", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-1", + role: .user, + text: "Repeat this prompt." + )) + ), + .init( + id: "ordinary-user-2", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user-2", + role: .user, + text: "Repeat this prompt." + )) + ), + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: "This is an ordinary response." + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let response = try #require( + chat.items(in: "ordinary-turn").first { $0.itemID == "ordinary-assistant" } + ) + #expect(response.origin == .currentV2Item) + #expect(response.semanticRelation == nil) + } + + @Test("live candidate does not infer a companion from summary items") + func liveCandidateDoesNotInferCompanionFromSummaryItems() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "summary-review-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .vscode, + turns: [ + .init( + id: "review-boundary", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + ] + ), + .init( + id: "summary-turn", + state: .completed, + itemsLoadState: .summary, + items: [ + .init( + id: "summary-user-1", + kind: .userMessage, + content: .message(.init( + id: "summary-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "summary-user-2", + kind: .userMessage, + content: .message(.init( + id: "summary-user-2", + role: .user, + text: "current changes" + )) + ), + ] + ), + ] + ), + workspace: nil + ) + _ = chat.apply(.itemCompleted( + .init( + id: "summary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "summary-assistant", + role: .assistant, + text: "This summary may omit narrative items." + )) + ), + turnID: "summary-turn" + )) + + let response = try #require( + chat.items(in: "summary-turn").first { + $0.itemID == "summary-assistant" + } + ) + #expect(response.origin == .currentV2Item) + #expect(response.semanticRelation == nil) + } + + @Test("review snapshot classifies a same-turn assistant after exit activity") + func reviewSnapshotClassifiesSameTurnAssistantAfterExitActivity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "review-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .subAgentReview, + turns: [ + .init( + id: "review-turn", + state: .completed, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "review-command", + kind: .commandExecution, + content: .command(.init(command: "/bin/zsh -lc")) + ), + .init( + id: "review-assistant", + kind: .agentMessage, + content: .message(.init( + id: "review-assistant", + role: .assistant, + text: "No issues found." + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let response = try #require( + chat.items(in: "review-turn").first { $0.itemID == "review-assistant" } + ) + #expect(response.origin == .reviewRolloutAssistant) + #expect(response.semanticRelation == .companionOf(.exitedReviewMode)) + } + + @Test("later review entry blocks a persisted companion from an earlier exit") + func laterReviewEntryBlocksPersistedCompanionFromEarlierExit() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = CodexChat(id: "review-chat", modelContext: context) + + chat.apply( + .init( + id: chat.id, + sourceKind: .subAgentReview, + turns: [ + .init( + id: "review-boundaries", + state: .completed, + items: [ + .init( + id: "prior-review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ), + .init( + id: "later-review-entry", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + .init( + id: "candidate-turn", + state: .completed, + items: [ + .init( + id: "candidate-user-1", + kind: .userMessage, + content: .message(.init( + id: "candidate-user-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "candidate-user-2", + kind: .userMessage, + content: .message(.init( + id: "candidate-user-2", + role: .user, + text: "current changes" + )) + ), + .init( + id: "candidate-assistant", + kind: .agentMessage, + content: .message(.init( + id: "candidate-assistant", + role: .assistant, + text: "Still running." + )) + ), + ] + ), + ] + ), + workspace: nil + ) + + let response = try #require( + chat.items(in: "candidate-turn").first { $0.itemID == "candidate-assistant" } + ) + #expect(response.origin == .currentV2Item) + #expect(response.semanticRelation == nil) + } + + @Test("started review coalesces multiple synthesized rollout records into the live turn") + func startedReviewCoalescesMultipleSynthesizedRolloutRecordsIntoLiveTurn() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + + let snapshot = CodexThreadSnapshot( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-read-1", + state: .inProgress, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes" + )) + ), + ] + ), + .init( + id: "rollout-read-2", + state: .inProgress, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes updated" + )) + ), + .init( + id: "item-2", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + ] + ) + + started.chat.apply(snapshot, workspace: started.chat.workspace) + started.chat.apply(snapshot, workspace: started.chat.workspace) + + #expect(started.chat.turns.map(\.id) == ["turn-seed"]) + #expect(started.chat.items(in: "turn-seed").map(\.itemID) == ["item-1", "item-2"]) + #expect(started.chat.items(in: "turn-seed").map(\.text) == [ + "current changes updated", "current changes", + ]) + } + + @Test("review mode markers preserve distinct raw identities") + func reviewModeMarkersPreserveDistinctRawIdentities() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + _ = started.chat.apply(.itemStarted( + .init( + id: "marker-live", + kind: .enteredReviewMode, + content: .log("current changes") + ), + turnID: "turn-seed" + )) + + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-read-1", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "current changes" + )) + ), + .init( + id: "item-2", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + #expect(started.chat.turns.map(\.id) == ["turn-seed"]) + #expect(started.chat.items.filter { $0.kind == .enteredReviewMode }.count == 2) + #expect(started.chat.items.filter { $0.kind == .userMessage }.count == 1) + } + + @Test("started review keeps prior review turns out of the live seed") + func startedReviewKeepsPriorReviewTurnsOutOfLiveSeed() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-seed", + reviewThreadID: "thread-review", + items: [ + .init( + id: "turn-seed", + kind: .userMessage, + content: .message(.init( + id: "turn-seed", + role: .user, + text: "current changes" + )) + ), + ] + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(model: "gpt-5", ephemeral: false) + ) + ) + _ = started.chat.apply(.turnStarted("turn-seed")) + + // A prior review's turn re-materializes with a never-seen synthesized + // id; its exitedReviewMode item marks it as finished history that must + // not be adopted into the live seeded turn. + started.chat.apply( + .init( + id: "thread-review", + workspace: workspaceURL, + status: .active(activeFlags: []), + turns: [ + .init( + id: "rollout-old-review", + state: .completed, + itemsLoadState: .full, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: "previous changes" + )) + ), + .init( + id: "item-2", + kind: .exitedReviewMode, + content: .log("Review finished") + ), + ] + ), + ] + ), + workspace: started.chat.workspace, + preservesExistingTurnItems: true + ) + + #expect(started.chat.turns.contains { $0.id == "rollout-old-review" }) + #expect(started.chat.turns.contains { $0.id == "turn-seed" }) + #expect(started.chat.items(in: "turn-seed").count == 1) + #expect(started.chat.items(in: "rollout-old-review").count == 2) + } + + @Test("started review preserves seeded row metadata across null metadata refresh") + func startedReviewPreservesSeededRowMetadataAcrossNullMetadataRefresh() async throws { + let workspaceURL = temporaryDirectory() + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + + try await runtime.transport.enqueueThreadStart(threadID: "thread-review", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-review", + reviewThreadID: "thread-review" + ) + try await runtime.transport.enqueueThreadTurns(.init(profile: .currentV2, turns: [ + .init( + id: "turn-review", + state: .inProgress, + itemsLoadState: .full, + items: [ + .init( + id: "review-mode", + kind: .enteredReviewMode, + content: .log("current changes") + ), + ] + ), + ])) + try await runtime.transport.enqueueJSON( + """ + { + "thread": { + "id": "thread-review", + "cwd": "\(workspaceURL.path)", + "name": null, + "preview": null, + "modelProvider": null + } + } + """, + for: "thread/read" + ) + + let started = try await context.startReview( + in: workspaceURL, + input: CodexReviewInput( + target: .uncommittedChanges, + options: .init(modelProvider: "openai", ephemeral: false) + ) + ) + let expectedPreview = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings." + #expect(started.chat.preview == expectedPreview) + #expect(started.chat.modelProvider == "openai") + + let observation = try await started.chat.observe() + defer { + observation.cancel() + } + + #expect(started.chat.preview == expectedPreview) + #expect(started.chat.title == expectedPreview) + #expect(started.chat.modelProvider == "openai") + } + + @Test("workspace start chat exposes known ephemeral option") + func workspaceStartChatExposesKnownEphemeralOption() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let workspaceURL = temporaryDirectory() + + try await runtime.transport.enqueueUserVisibleThreadList( + .init(profile: .currentV2, threads: [ + .init(id: "thread-existing", workspace: workspaceURL, name: "Existing") + ])) + let workspaceResults = context.fetchedResults( + for: CodexFetchDescriptor.workspaces) + try await workspaceResults.performFetch() + let workspace = try #require(workspaceResults.items.first) + + try await runtime.transport.enqueueThreadStart(threadID: "thread-ephemeral") + let chat = try await workspace.startChat(.init(options: .init(ephemeral: true))) + + #expect(chat.ephemeral == true) + } +} + +private func makeDataKitStoredThreadFixture( + id: CodexThreadID, + workspace: URL, + name: String? = nil, + preview: String? = nil, + model: String = "gpt-5", + modelProvider: String = "openai", + source: CodexAppServerTestSessionSource = .cli, + createdAt: Date = Date(timeIntervalSince1970: 10), + updatedAt: Date = Date(timeIntervalSince1970: 20), + ephemeral: Bool = false, + turns: [CodexAppServerTestTurn] = [], + isArchived: Bool = false +) throws -> CodexAppServerTestStoredThread { + try .init( + snapshot: .init( + id: id, + workspace: workspace, + name: name, + preview: preview ?? id.rawValue, + modelProvider: modelProvider, + sourceKind: source.sourceKind, + createdAt: createdAt, + updatedAt: updatedAt, + status: .idle, + ephemeral: ephemeral, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: source + ), + runtimeMetadata: .init( + model: model, + modelProvider: modelProvider, + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: isArchived + ) +} + +enum DataKitTestFixtureProfile { + case currentV2 + case partialDTO + + var workspace: URL { + URL(fileURLWithPath: "/tmp/codex-data-kit-current-v2", isDirectory: true) + } + + var model: String { "gpt-5" } + var modelProvider: String { "openai" } + var source: CodexThreadSourceKind { .cli } + var referenceDate: Date { Date(timeIntervalSince1970: 0) } +} + +struct DataKitTestThreadFixture { + var id: CodexThreadID + var workspace: URL? + var name: String? + var preview: String? + var modelProvider: String? + var sourceKind: CodexThreadSourceKind? + var createdAt: Date? + var updatedAt: Date? + var recencyAt: Date? + var status: CodexThreadStatus? + var ephemeral: Bool? + var turns: [DataKitTestTurnFixture]? + + init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + modelProvider: String? = nil, + sourceKind: CodexThreadSourceKind? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + recencyAt: Date? = nil, + status: CodexThreadStatus? = nil, + ephemeral: Bool? = nil, + turns: [DataKitTestTurnFixture]? = nil + ) { + self.id = id + self.workspace = workspace + self.name = name + self.preview = preview + self.modelProvider = modelProvider + self.sourceKind = sourceKind + self.createdAt = createdAt + self.updatedAt = updatedAt + self.recencyAt = recencyAt + self.status = status + self.ephemeral = ephemeral + self.turns = turns + } + + func withSourceKind(_ sourceKind: CodexThreadSourceKind) -> Self { + var fixture = self + fixture.sourceKind = sourceKind + return fixture + } + + func storedThread( + profile: DataKitTestFixtureProfile, + model: String? = nil + ) throws -> CodexAppServerTestStoredThread { + guard profile == .currentV2 else { + preconditionFailure("Partial DTO fixtures do not create opaque stored threads.") + } + let turns = try (turns ?? []).map { try $0.turn(profile: profile) } + let workspace = workspace ?? profile.workspace + let modelProvider = modelProvider ?? profile.modelProvider + let source = sourceKind ?? profile.source + let createdAt = (createdAt ?? profile.referenceDate).wholeSecondForFixture + let updatedAt = (updatedAt ?? createdAt).wholeSecondForFixture + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + name: name, + preview: preview ?? name ?? id.rawValue, + modelProvider: modelProvider, + source: source.testSessionSource.domainProjection, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt?.wholeSecondForFixture, + status: status ?? .idle, + ephemeral: ephemeral ?? false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "data-kit-session-\(id.rawValue)", + parentThreadID: source == .subAgentThreadSpawn + ? "data-kit-testing-parent" + : nil, + cliVersion: "codex-data-kit-tests", + source: source.testSessionSource + ), + runtimeMetadata: .init( + model: model ?? profile.model, + modelProvider: modelProvider, + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) + } +} + +struct DataKitTestTurnFixture { + var id: CodexTurnID + var state: CodexTurnSnapshot.State + var itemsLoadState: CodexTurnItemsLoadState + var items: [CodexThreadItem] + var startedAt: Date? + var completedAt: Date? + var duration: Duration? + + init( + id: CodexTurnID, + state: CodexTurnSnapshot.State, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexThreadItem] = [], + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) { + self.id = id + self.state = state + self.itemsLoadState = itemsLoadState + self.items = items + self.startedAt = startedAt + self.completedAt = completedAt + self.duration = duration + } + + func turn( + profile: DataKitTestFixtureProfile + ) throws -> CodexAppServerTestTurn { + let items = try items.map { try $0.testItem(profile: profile) } + return try .init( + snapshot: .init( + id: id, + state: state, + itemsLoadState: itemsLoadState, + items: items.map(\.domainProjection), + startedAt: startedAt, + completedAt: completedAt, + duration: duration + ), + items: items + ) + } + + func dto( + profile: DataKitTestFixtureProfile + ) throws -> AppServerAPI.Turn.Payload { + let turn = try turn(profile: profile) + return try JSONDecoder().decode( + AppServerAPI.Turn.Payload.self, + from: JSONEncoder().encode(turn.wireValue) + ) + } +} + +struct DataKitTestThreadPage { + enum Payload { + case opaque(CodexAppServerTestThreadPage) + case dto(AppServerAPI.Thread.List.Response) + } + + var payload: Payload + + init( + profile: DataKitTestFixtureProfile, + threads: [DataKitTestThreadFixture], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) throws { + switch profile { + case .currentV2: + self.payload = try .opaque(.init( + threads: threads.map { try $0.storedThread(profile: profile) }, + nextCursor: nextCursor, + backwardsCursor: backwardsCursor + )) + case .partialDTO: + self.payload = try .dto(.init( + data: threads.map { try $0.dto(profile: profile) }, + nextCursor: nextCursor, + backwardsCursor: backwardsCursor + )) + } + } +} + +struct DataKitTestTurnPage { + var page: CodexAppServerTestTurnPage + + init( + profile: DataKitTestFixtureProfile, + turns: [DataKitTestTurnFixture], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) throws { + self.page = try .init( + turns: turns.map { try $0.turn(profile: profile) }, + nextCursor: nextCursor, + backwardsCursor: backwardsCursor + ) + } +} + +extension CodexAppServerTestTransport { + func enqueueUserVisibleThreadListJSON(_ interactiveResponse: String) throws { + try enqueueJSON(interactiveResponse, for: "thread/list") + try enqueueThreadList(.init(threads: [])) + } + + func enqueueUserVisibleThreadList(_ page: CodexAppServerTestThreadPage) throws { + try enqueueThreadList(page) + guard page.nextCursor == nil else { + return + } + try enqueueThreadList(.init(threads: [])) + } + + func enqueueUserVisibleThreadList(_ page: DataKitTestThreadPage) throws { + switch page.payload { + case .opaque(let page): + try enqueueUserVisibleThreadList(page) + case .dto(let response): + try enqueue(response, for: "thread/list") + guard response.nextCursor == nil else { + return + } + try enqueue( + AppServerAPI.Thread.List.Response( + data: [], + nextCursor: nil, + backwardsCursor: nil + ), + for: "thread/list" + ) + } + } + + func enqueueBoundedUserVisibleThreadList(_ page: CodexAppServerTestThreadPage) throws { + try enqueueThreadList(page) + try enqueueThreadList(.init(threads: [])) + } + + func enqueueBoundedUserVisibleThreadList(_ page: DataKitTestThreadPage) throws { + switch page.payload { + case .opaque(let page): + try enqueueBoundedUserVisibleThreadList(page) + case .dto(let response): + try enqueue(response, for: "thread/list") + try enqueue( + AppServerAPI.Thread.List.Response( + data: [], + nextCursor: nil, + backwardsCursor: nil + ), + for: "thread/list" + ) + } + } + + func enqueueThreadList(_ page: DataKitTestThreadPage) throws { + switch page.payload { + case .opaque(let page): + try enqueueThreadList(page) + case .dto(let response): + try enqueue(response, for: "thread/list") + } + } + + func enqueueThreadTurns(_ page: DataKitTestTurnPage) throws { + try enqueueThreadTurns(page.page) + } + + func enqueueThreadStart(threadID: String, model: String? = nil) throws { + try enqueueThreadStart( + DataKitTestThreadFixture(id: .init(rawValue: threadID)) + .storedThread(profile: .currentV2, model: model) + ) + } + + func enqueueThreadResume( + _ thread: DataKitTestThreadFixture, + model: String? = nil + ) throws { + try enqueueThreadResume( + thread.storedThread(profile: .currentV2, model: model) + ) + } + + func enqueueThreadRead(_ thread: DataKitTestThreadFixture) throws { + try enqueueThreadRead(thread.storedThread(profile: .currentV2)) + } + + func enqueueThreadUnarchive(_ thread: DataKitTestThreadFixture) throws { + try enqueueThreadUnarchive(thread.storedThread(profile: .currentV2)) + } + + func enqueueTurnStart( + turnID: String, + status: String = "inProgress" + ) throws { + try enqueueTurnStart(try DataKitTestTurnFixture( + id: .init(rawValue: turnID), + state: status.testTurnState + ).turn(profile: .currentV2)) + } + + func enqueueReviewStart( + turnID: String, + reviewThreadID: String, + status: CodexTurnStatus = .inProgress, + items: [CodexThreadItem] = [] + ) throws { + try enqueueReviewStart( + try DataKitTestTurnFixture( + id: .init(rawValue: turnID), + state: status.testTurnState, + items: items + ).turn(profile: .currentV2), + reviewThreadID: .init(rawValue: reviewThreadID) + ) + } + + func enqueueReviewStart( + _ turn: DataKitTestTurnFixture, + reviewThreadID: String + ) throws { + try enqueueReviewStart( + try turn.turn(profile: .currentV2), + reviewThreadID: .init(rawValue: reviewThreadID) + ) + } +} + +private extension DataKitTestThreadFixture { + func dto( + profile: DataKitTestFixtureProfile + ) throws -> AppServerAPI.Thread.Snapshot { + .init( + id: id.rawValue, + cwd: workspace?.path, + name: name, + preview: preview, + modelProvider: modelProvider, + source: sourceKind.map { $0.testSessionSource.appServerValue }, + createdAt: createdAt.map { Int($0.timeIntervalSince1970) }, + updatedAt: updatedAt.map { Int($0.timeIntervalSince1970) }, + recencyAt: recencyAt.map { Int($0.timeIntervalSince1970) }, + status: status.map { status in + switch status { + case .active(let activeFlags): + .init(type: status.rawValue, activeFlags: activeFlags.map(\.rawValue)) + case .notLoaded, .idle, .systemError, .unknown: + .init(type: status.rawValue) + } + }, + ephemeral: ephemeral, + turns: try turns?.map { try $0.dto(profile: profile) } + ) + } +} + +private extension CodexThreadItem { + func testItem( + profile: DataKitTestFixtureProfile + ) throws -> CodexAppServerTestItem { + switch (kind, content) { + case (.userMessage, .message(let message)): + return try .userMessage(id: id, text: message.text) + case (.agentMessage, .message(let message)): + return try .agentMessage(id: id, text: message.text, phase: message.phase) + case (.enteredReviewMode, .log(let review)): + return try .enteredReviewMode(id: id, review: review) + case (.exitedReviewMode, .log(let review)): + return try .exitedReviewMode(id: id, review: review) + case (.reasoning, .reasoning(let reasoning)): + return try .reasoning(id: id, summary: reasoning.summary, content: reasoning.content) + case (.commandExecution, .command(let command)): + return try .commandExecution( + id: id, + command: command.command, + cwd: URL( + fileURLWithPath: command.cwd ?? profile.workspace.path, + isDirectory: true + ), + processID: command.processID, + source: .agent, + status: command.status.testCommandStatus, + aggregatedOutput: command.output, + exitCode: command.exitCode.flatMap(Int32.init(exactly:)), + duration: command.duration + ) + case (.mcpToolCall, .toolCall(let call)): + guard let server = call.server, let tool = call.name else { + throw CodexAppServerTestError.invalidFixture( + "DataKit current-v2 MCP fixtures require server and tool names." + ) + } + return try .mcpToolCall( + id: id, + server: server, + tool: tool, + status: call.status.testMCPStatus, + resultContent: call.result.map { [.string($0)] }, + errorMessage: call.error + ) + default: + throw CodexAppServerTestError.invalidFixture( + "Unsupported DataKit current-v2 item fixture \(kind.rawValue)." + ) + } + } +} + +private extension Optional where Wrapped == CodexTurnStatus { + var testCommandStatus: CodexAppServerTestItem.CommandStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } + } + + var testMCPStatus: CodexAppServerTestItem.MCPStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } + } +} + +private extension Date { + var wholeSecondForFixture: Date { + Date(timeIntervalSince1970: timeIntervalSince1970.rounded(.towardZero)) + } +} + +private extension String { + var testTurnState: CodexTurnSnapshot.State { + switch self { + case "inProgress", "running": + .inProgress + case "completed": + .completed + case "interrupted": + .interrupted + default: + .unknown(rawValue: self, error: nil) + } + } +} + +private extension CodexTurnStatus { + var testTurnState: CodexTurnSnapshot.State { + switch self { + case .inProgress: + .inProgress + case .completed: + .completed + case .interrupted: + .interrupted + case .failed: + .failed(.init(message: "Testing review failure")) + case .unknown(let rawValue): + .unknown(rawValue: rawValue, error: nil) + } + } +} + +private extension CodexThreadSourceKind { + var testSessionSource: CodexAppServerTestSessionSource { + switch self { + case .cli: + .cli + case .vscode: + .vscode + case .exec: + .exec + case .appServer: + .appServer + case .subAgentReview: + .subAgentReview + case .subAgentCompact: + .subAgentCompact + case .subAgentThreadSpawn: + .subAgentThreadSpawn( + parentThreadID: "data-kit-testing-parent", + depth: 0, + agentPath: nil, + agentNickname: nil, + agentRole: nil + ) + case .subAgentOther: + .subAgentOther("data-kit-testing") + case .subAgent: + .subAgentMemoryConsolidation + case .unknown: + .unknown + default: + .custom(rawValue) + } + } +} + +private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) +} + +private func createDirectory(_ name: String, in parent: URL) throws -> URL { + let url = parent.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func gitRepository() throws -> URL { + let repo = temporaryDirectory() + try createGitMetadata(in: repo) + return repo +} + +private func gitRepository(named name: String) throws -> URL { + let repo = temporaryDirectory().appendingPathComponent(name, isDirectory: true) + try createGitMetadata(in: repo) + return repo +} + +private func createGitMetadata(in repo: URL) throws { + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: repo.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) +} + +private struct ThreadListParams: Decodable, Sendable { + var archived: Bool? + var cursor: String? + var cwd: CWDFilter? + var limit: Int? + var modelProviders: [String]? + var searchTerm: String? + var sortDirection: String? + var sortKey: String? + var sourceKinds: [String]? + var useStateDbOnly: Bool? +} + +private enum CWDFilter: Decodable, Equatable, Sendable { + case path(String) + case paths([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let path = try? container.decode(String.self) { + self = .path(path) + } else { + self = .paths(try container.decode([String].self)) + } + } +} + +private struct ThreadReadParams: Decodable, Sendable { + var threadID: String + var includeTurns: Bool? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case includeTurns + } +} + +private struct ThreadTurnsListParams: Decodable, Sendable { + var threadID: String + var cursor: String? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case cursor + } +} + +private struct ThreadStartParams: Decodable, Sendable { + var cwd: String? + var model: String? + var modelProvider: String? + var ephemeral: Bool? +} + +private struct ThreadItemParams: Encodable, Sendable { + enum Lifecycle: Sendable { + case started + case completed + } + + var lifecycle: Lifecycle + var threadID: String + var turnID: String + var startedAtMs: Int64? = nil + var completedAtMs: Int64? = nil + var item: Item + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case startedAtMs + case completedAtMs + case item + } + + init( + lifecycle: Lifecycle, + threadID: String, + turnID: String, + startedAtMs: Int64? = nil, + completedAtMs: Int64? = nil, + item: Item + ) { + self.lifecycle = lifecycle + self.threadID = threadID + self.turnID = turnID + self.startedAtMs = startedAtMs + self.completedAtMs = completedAtMs + self.item = item + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(threadID, forKey: .threadID) + try container.encode(turnID, forKey: .turnID) + switch lifecycle { + case .started: + try container.encode(startedAtMs ?? 0, forKey: .startedAtMs) + case .completed: + try container.encode(completedAtMs ?? 0, forKey: .completedAtMs) + } + var item = item + switch item.type { + case "commandExecution": + item.command = item.command ?? item.text ?? "" + item.cwd = item.cwd ?? "/workspace" + item.commandActions = [] + item.aggregatedOutput = item.output ?? item.text + item.status = lifecycle == .started ? "inProgress" : "completed" + case "fileChange": + item.status = lifecycle == .started ? "inProgress" : "completed" + item.changes = [ + .init( + diff: item.text ?? item.output ?? "", + kind: .init(type: "update"), + path: item.path ?? "/workspace/File.swift" + ) + ] + case "enteredReviewMode", "exitedReviewMode": + item.review = item.text ?? "" + default: + break + } + try container.encode(item, forKey: .item) + } + + struct Item: Encodable, Sendable { + var id: String + var type: String + var text: String? = nil + var phase: String? = nil + var command: String? = nil + var cwd: String? = nil + var path: String? = nil + var output: String? = nil + var exitCode: Int? = nil + var status: String? = nil + var durationMs: Int? = nil + var aggregatedOutput: String? = nil + var commandActions: [String]? = nil + var review: String? = nil + var changes: [FileChange]? = nil + + struct FileChange: Encodable, Sendable { + var diff: String + var kind: Kind + var path: String + + struct Kind: Encodable, Sendable { + var type: String + } + } + } +} + +private struct TurnStartedParams: Encodable, Sendable { + var threadID: String + var turn: Turn + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + init(threadID: String, turnID: String) { + self.threadID = threadID + self.turn = .init(id: turnID) + } + + struct Turn: Encodable, Sendable { + var id: String + var status = "inProgress" + var items: [String] = [] + } +} + +private struct TurnDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var delta: String + var phase: String? + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case delta + case phase + } +} + +private struct OutputDeltaParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var delta: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case delta + } +} + +private struct FileChangePatchUpdatedParams: Encodable, Sendable { + var threadID: String + var turnID: String + var itemID: String + var changes: [Change] + + init(threadID: String, turnID: String, itemID: String, displayText: String) { + self.threadID = threadID + self.turnID = turnID + self.itemID = itemID + self.changes = [ + .init( + diff: displayText, + kind: .init(type: "update"), + path: "Sources/File.swift" + ) + ] + } + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case itemID = "itemId" + case changes + } + + struct Change: Encodable, Sendable { + var diff: String + var kind: Kind + var path: String + + struct Kind: Encodable, Sendable { + var type: String + } + } +} + +private struct ThreadStatusParams: Encodable, Sendable { + var threadID: String + var status: Status + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case status + } + + struct Status: Encodable, Sendable { + var type: String + var activeFlags: [String] = [] + } +} + +private struct ThreadClosedParams: Encodable, Sendable { + var threadID: String + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + } +} + +private struct TurnCompletedParams: Encodable, Sendable { + var threadID: String + var turn: Turn + + init(threadID: String, turn: Turn) { + self.threadID = threadID + self.turn = turn + } + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turn + } + + struct Turn: Encodable, Sendable { + var id: String + var status: String + var items: [String] + var completedAt: Int? + var error: Error? + + init( + id: String, + status: String, + completedAt: Int? = nil, + error: Error? = nil, + items: [String] = [] + ) { + self.id = id + self.status = status + self.completedAt = completedAt + self.error = error + self.items = items + } + } + + struct Error: Encodable, Sendable { + var message: String + var codexErrorInfo: String? + var additionalDetails: String? + + init( + message: String, + codexErrorInfo: String? = nil, + additionalDetails: String? = nil + ) { + self.message = message + self.codexErrorInfo = codexErrorInfo + self.additionalDetails = additionalDetails + } + } +} + +private struct TokenUsageParams: Encodable, Sendable { + var threadID: String + var turnID: String + var tokenUsage: TokenUsage + + enum CodingKeys: String, CodingKey { + case threadID = "threadId" + case turnID = "turnId" + case tokenUsage + } + + struct TokenUsage: Encodable, Sendable { + var last: Breakdown + var total: Breakdown + var modelContextWindow: Int? + + init(total: Breakdown, modelContextWindow: Int? = nil) { + self.last = total + self.total = total + self.modelContextWindow = modelContextWindow + } + } + + struct Breakdown: Encodable, Sendable { + var cachedInputTokens: Int = 0 + var inputTokens: Int + var outputTokens: Int + var reasoningOutputTokens: Int = 0 + var totalTokens: Int + } +} + +private func emitAgentMessageStarted( + on transport: CodexAppServerTestTransport, + threadID: String, + turnID: String, + itemID: String, + text: String = "", + phase: String? = nil +) async throws { + try await transport.emitServerNotification( + method: "item/started", + params: ThreadItemParams( + lifecycle: .started, + threadID: threadID, + turnID: turnID, + item: .init( + id: itemID, + type: "agentMessage", + text: text, + phase: phase + ) + ) + ) +} + +@MainActor +private func eventually( + attempts: Int = 50, + _ condition: @MainActor () async -> Bool +) async -> Bool { + for _ in 0.. [CodexThreadItem] { + items.map { + CodexThreadItem( + id: $0.itemID, + kind: $0.kind, + content: $0.content, + rawPayload: $0.rawPayload + ) + } +} + +@MainActor +private final class FetchedResultsTransactionRecorder { + private(set) var transactions: [CodexFetchedResultsTransaction] = [] + private var task: Task? + + init(stream: AsyncStream>) { + task = Task { @MainActor [weak self] in + for await transaction in stream { + self?.transactions.append(transaction) + } + } + } + + deinit { + task?.cancel() + } + + func count(after delay: Duration) async -> Int { + try? await Task.sleep(for: delay) + return transactions.count + } +} + +@MainActor +private final class ChatUpdateRecorder { + private var changes: [CodexChatUpdate] = [] + private var snapshots: [(CodexChatObservationSnapshot, CodexChatSnapshotReason)] = [] + private var streamFinished = false + private var task: Task? + + init(stream: CodexChatUpdates) { + task = Task { @MainActor [weak self] in + for await event in stream { + self?.append(event) + } + self?.markFinished() + } + } + + deinit { + task?.cancel() + } + + func next() async -> CodexChatUpdate? { + await next { _ in true } + } + + func itemInserted(id: String) async -> CodexChatUpdate? { + await next { change in + if case .itemInserted(let item, _, _) = change { + return item.id == id + } + if case .turnInserted(let turn, _) = change { + return turn.items.contains { $0.id == id } + } + return false + } + } + + func itemUpdated(id: String) async -> CodexChatUpdate? { + await next { change in + if case .itemUpdated(let item, _, _) = change { + return item.id == id + } + if case .turnUpdated(let turn, _) = change { + return turn.items.contains { $0.id == id } + } + return false + } + } + + func itemRemoved(id: String) async -> CodexChatUpdate? { + await next { change in + if case .itemRemoved(let locator) = change { + return locator.id == id + } + return false + } + } + + func itemTextAppended(id: String, delta: String) async -> CodexChatUpdate? { + await next { change in + if case .itemTextAppended(let locator, let changeDelta) = change { + return locator.id == id && changeDelta == delta + } + return false + } + } + + func phaseChanged(_ phase: CodexChatPhase) async -> CodexChatUpdate? { + await next { change in + if case .phaseChanged(let candidate) = change { + return candidate == phase + } + return false + } + } + + func statusChanged(_ status: CodexThreadStatus?) async -> CodexChatUpdate? { + await next { change in + if case .statusChanged(let candidate) = change { + return candidate == status + } + return false + } + } + + func snapshot(reason: CodexChatSnapshotReason) async -> CodexChatObservationSnapshot? { + for _ in 0..<50 { + if let index = snapshots.firstIndex(where: { $0.1 == reason }) { + return snapshots.remove(at: index).0 + } + try? await Task.sleep(for: .milliseconds(10)) + } + return nil + } + + var isFinished: Bool { + streamFinished + } + + private func append(_ event: CodexChatObservationEvent) { + switch event.payload { + case .update(let change): + changes.append(change) + case .snapshot(let snapshot, let reason): + snapshots.append((snapshot, reason)) + } + } + + private func markFinished() { + streamFinished = true + } + + private func popFirst( + matching predicate: (CodexChatUpdate) -> Bool + ) -> CodexChatUpdate? { + guard let index = changes.firstIndex(where: predicate) else { + return nil + } + return changes.remove(at: index) + } + + private func next( + matching predicate: (CodexChatUpdate) -> Bool + ) async -> CodexChatUpdate? { + for _ in 0..<50 { + if let change = popFirst(matching: predicate) { + return change + } + try? await Task.sleep(for: .milliseconds(10)) + } + return popFirst(matching: predicate) + } +} diff --git a/Tests/CodexDataKitTests/CodexItemIdentityTests.swift b/Tests/CodexDataKitTests/CodexItemIdentityTests.swift new file mode 100644 index 00000000..9376c8e9 --- /dev/null +++ b/Tests/CodexDataKitTests/CodexItemIdentityTests.swift @@ -0,0 +1,197 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Foundation +import Testing + +@Suite("Codex item identity") +@MainActor +struct CodexItemIdentityTests { + @Test("message deltas use their required item identity") + func messageDeltasUseRequiredItemIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-message-delta")) + let turnID = CodexTurnID(rawValue: "turn-message-delta") + + _ = chat.apply(.turnStarted(turnID)) + _ = chat.apply(.messageDelta( + CodexMessageDelta(text: "Hello", itemID: "message-live"), + turnID: turnID + )) + let item = try #require(chat.items.first) + _ = chat.apply(.messageDelta( + CodexMessageDelta(text: " world", itemID: "message-live"), + turnID: turnID + )) + + #expect(chat.items.count == 1) + #expect(chat.items.first === item) + #expect(item.itemID == "message-live") + #expect(item.message?.text == "Hello world") + await runtime.close() + } + + @Test("authoritative snapshots reuse the same message identity") + func authoritativeSnapshotsReuseSameMessageIdentity() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-message-snapshot")) + let turnID = CodexTurnID(rawValue: "turn-message-snapshot") + + _ = chat.apply(.turnStarted(turnID)) + _ = chat.apply(.messageDelta( + CodexMessageDelta(text: "Final answer", itemID: "message-real"), + turnID: turnID + )) + let liveItem = try #require(chat.items.first) + let rawPayload = Data(#"{"id":"message-real","type":"agent_message"}"#.utf8) + + _ = chat.apply(.completed(CodexResponse( + turnID: turnID, + transcript: .init(items: [ + agentMessageItem( + id: "message-real", + text: "Final answer", + phase: .finalAnswer, + rawPayload: rawPayload + ), + ]) + ))) + + let snapshotItem = try #require(chat.items.first) + #expect(chat.items.count == 1) + #expect(snapshotItem === liveItem) + #expect(snapshotItem.itemID == "message-real") + #expect(snapshotItem.rawPayload == rawPayload) + #expect(snapshotItem.message?.phase == .finalAnswer) + await runtime.close() + } + + @Test("equal message text does not replace distinct identities") + func equalMessageTextDoesNotReplaceDistinctIdentities() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-distinct-messages")) + let turnID = CodexTurnID(rawValue: "turn-distinct-messages") + + chat.apply( + CodexThreadSnapshot( + id: chat.id, + turns: [ + .init( + id: turnID, + state: .completed, + itemsLoadState: .full, + items: [ + agentMessageItem(id: "message-a", text: "Same text"), + agentMessageItem(id: "message-b", text: "Same text"), + ] + ), + ] + ), + workspace: Optional.none + ) + + #expect(chat.items.map(\.itemID) == ["message-a", "message-b"]) + #expect(chat.items.map(\.text) == ["Same text", "Same text"]) + await runtime.close() + } + + @Test("review marker identity retains raw ID and kind") + func reviewMarkerIdentityRetainsRawIDAndKind() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let context = CodexModelContainer(appServer: runtime.server).mainContext + let chat = context.model(for: CodexThreadID(rawValue: "thread-review-marker-identity")) + let turnID = CodexTurnID(rawValue: "turn-review-marker-identity") + + chat.apply( + CodexThreadSnapshot( + id: chat.id, + turns: [ + .init( + id: turnID, + state: .completed, + itemsLoadState: .full, + items: [ + reviewMarkerItem( + id: "review-marker", + kind: .enteredReviewMode, + text: "entered" + ), + reviewMarkerItem( + id: "review-marker", + kind: .exitedReviewMode, + text: "exited" + ), + reviewMarkerItem( + id: "8f80d976-f70d-4d37-af93-f8ba57fb802f", + kind: .enteredReviewMode, + text: "entered again" + ), + ] + ), + ] + ), + workspace: Optional.none + ) + + #expect(chat.items.count == 3) + #expect(chat.items.map(\.kind) == [ + .enteredReviewMode, + .exitedReviewMode, + .enteredReviewMode, + ]) + #expect(Set(chat.items.map(\.id)).count == 3) + #expect(chat.items.map(\.itemID) == [ + "review-marker", + "review-marker", + "8f80d976-f70d-4d37-af93-f8ba57fb802f", + ]) + #expect(chat.items.map(\.id.rawValue) == [ + "turn-review-marker-identity:enteredReviewMode:review-marker", + "turn-review-marker-identity:exitedReviewMode:review-marker", + "turn-review-marker-identity:enteredReviewMode:8f80d976-f70d-4d37-af93-f8ba57fb802f", + ]) + let locators = chat.items.map { + CodexChatItemLocator( + id: $0.itemID, + kind: $0.kind, + turnID: turnID + ) + } + #expect(Set(locators).count == 3) + await runtime.close() + } + + private func agentMessageItem( + id: String, + text: String, + phase: CodexMessagePhase? = nil, + rawPayload: Data? = nil + ) -> CodexThreadItem { + CodexThreadItem( + id: id, + kind: .agentMessage, + content: .message(.init( + id: id, + role: .assistant, + phase: phase, + text: text + )), + rawPayload: rawPayload + ) + } + + private func reviewMarkerItem( + id: String, + kind: CodexThreadItem.Kind, + text: String + ) -> CodexThreadItem { + CodexThreadItem( + id: id, + kind: kind, + content: .log(text) + ) + } +} From 88fd74cf6b4d31952e71c7651c26f0c2b57fba6a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:29:21 +0900 Subject: [PATCH 5/5] docs(package): update integrated module guidance --- Sources/CodexAppServerKit/README.md | 9 +++++---- Sources/CodexDataKit/README.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/CodexAppServerKit/README.md b/Sources/CodexAppServerKit/README.md index 6a7867e9..dfe5d17d 100644 --- a/Sources/CodexAppServerKit/README.md +++ b/Sources/CodexAppServerKit/README.md @@ -331,10 +331,11 @@ let cancellation = try await restored.cancel() noteActiveTurnThread(cancellation.threadID) ``` -`CodexReviewIdentity` is a `Codable` Swift value containing only CodexKit -identity: source thread, review turn, optional detached review thread, and -active review thread model when known. It is intended for persisted app-server -review runs and does not depend on any higher-level review domain model. +`CodexReviewIdentity` is a `Codable` Swift value containing only app-server +review identity fields: source thread, review turn, optional detached review +thread, and active review thread model when known. It is intended for persisted +app-server review runs and does not depend on any higher-level review domain +model. `CodexAppServer` also owns app-server review restart and cleanup lifecycle state. A host that needs to interrupt and restart a review can prepare a diff --git a/Sources/CodexDataKit/README.md b/Sources/CodexDataKit/README.md index e3d949c5..4e4f338e 100644 --- a/Sources/CodexDataKit/README.md +++ b/Sources/CodexDataKit/README.md @@ -2,7 +2,7 @@ CodexDataKit provides SwiftData-style `@Observable` app-server backed models on top of `CodexAppServerKit`. -Use this package when app or UI code needs workspace group, workspace, and chat models without rendering directly from JSON-RPC payloads. +Use this product when app or UI code needs workspace group, workspace, and chat models without rendering directly from JSON-RPC payloads. ## Main Types